diff --git a/.gitignore b/.gitignore index 0c22e1f..5ce16ad 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .php_cs .php_cs.cache .phpunit.result.cache +.phpunit.cache build composer.lock coverage diff --git a/README.md b/README.md index 855de3e..0edd936 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,25 @@ [![codecov](https://codecov.io/gh/farzai/transport-php/branch/main/graph/badge.svg)](https://codecov.io/gh/farzai/transport-php) [![Total Downloads](https://img.shields.io/packagist/dt/farzai/transport.svg?style=flat-square)](https://packagist.org/packages/farzai/transport) -A HTTP client for Farzai Package. +A modern, PSR-compliant HTTP client for PHP with middleware architecture, advanced retry strategies, and fluent API for building requests. +## Features + +- ✅ **PSR Standards** - Built on PSR-7 (HTTP Messages), PSR-17 (HTTP Factories), PSR-18 (HTTP Client) +- ✅ **No Hard Dependencies** - Use any PSR-18 HTTP client (Guzzle, Symfony, or custom) +- ✅ **Auto-Detection** - Automatically discovers and uses available HTTP clients +- ✅ **Middleware Architecture** - Extensible plugin system for custom behavior +- ✅ **Advanced Retry Strategies** - Exponential backoff with jitter, custom retry conditions +- ✅ **Fluent Request Builder** - Chainable API for building requests +- ✅ **Immutable Configuration** - Thread-safe, predictable behavior +- ✅ **Type-Safe** - Full PHP 8.1+ type hints and strict types +- ✅ **JSON Helpers** - Parse JSON with proper error handling and dot-notation access +- ✅ **Custom Exceptions** - Detailed error context implementing PSR standards +- ✅ **Easy to Swap HTTP Clients** - Switch between Guzzle, Symfony, or any PSR-18 client + +## Requirements + +- PHP 8.1 or higher ## Installation @@ -16,12 +33,384 @@ You can install the package via composer: composer require farzai/transport ``` +The library will auto-detect any available PSR-18 HTTP client. If you don't have one installed, we recommend: + +```bash +# Recommended: Modern HTTP client with async support +composer require symfony/http-client + +# Alternative: Popular and widely-used +composer require guzzlehttp/guzzle +``` + +## Quick Start + +Transport PHP automatically detects available HTTP clients (Symfony, Guzzle, etc.) - no configuration needed! + +```php +use Farzai\Transport\TransportBuilder; + +// Just works! Auto-detects your HTTP client +$transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->build(); + +$response = $transport->get('/users')->send(); +echo $response->json('data.0.name'); // Dot notation support! +``` + +## Usage + +### Basic Usage (Fluent API) + +```php +use Farzai\Transport\TransportBuilder; + +// Create a transport client with configuration +$transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->withHeaders([ + 'Authorization' => 'Bearer token123', + 'Accept' => 'application/json', + ]) + ->withTimeout(30) + ->build(); + +// Make requests using fluent API +$response = $transport->get('/users/123')->send(); + +// Access response data +echo $response->statusCode(); // 200 +echo $response->body(); // Raw response body +$data = $response->json(); // Parsed JSON as array +``` + +### Fluent Request Building + +```php +// GET request with query parameters +$response = $transport + ->get('/users') + ->withQuery(['page' => 1, 'limit' => 10]) + ->withHeader('X-Custom-Header', 'value') + ->send(); + +// POST with JSON body +$response = $transport + ->post('/users') + ->withJson([ + 'name' => 'John Doe', + 'email' => 'john@example.com' + ]) + ->send(); + +// POST with form data +$response = $transport + ->post('/login') + ->withForm([ + 'username' => 'john', + 'password' => 'secret' + ]) + ->send(); + +// With authentication +$response = $transport + ->get('/protected') + ->withBearerToken('your-token') + ->send(); + +// Or basic auth +$response = $transport + ->get('/protected') + ->withBasicAuth('username', 'password') + ->send(); +``` + +### Working with JSON Responses + +```php +// Parse JSON with automatic error handling +$data = $response->json(); // ['id' => 123, 'name' => 'John Doe'] + +// Get specific field using dot notation +$name = $response->json('name'); // 'John Doe' +$city = $response->json('user.address.city'); // 'New York' + +// Get as array +$array = $response->toArray(); + +// Safe JSON parsing (returns null on error instead of throwing) +$data = $response->jsonOrNull(); + +// Check if response is successful +if ($response->isSuccessful()) { + // Handle success (2xx status codes) +} +``` + +### Advanced Retry Logic + +```php +use Farzai\Transport\Retry\ExponentialBackoffStrategy; +use Farzai\Transport\Retry\RetryCondition; +use Farzai\Transport\Exceptions\NetworkException; + +// Configure retry with exponential backoff +$transport = TransportBuilder::make() + ->withRetries( + maxRetries: 3, + strategy: new ExponentialBackoffStrategy( + baseDelayMs: 1000, // Start with 1 second + multiplier: 2.0, // Double each retry + maxDelayMs: 30000, // Cap at 30 seconds + useJitter: true // Add randomization + ), + condition: RetryCondition::default() + ->onExceptions([NetworkException::class]) + ) + ->build(); + +// Retries automatically with exponential backoff + jitter +$response = $transport->get('/unreliable-endpoint')->send(); +``` + +### Custom Middleware + +```php +use Farzai\Transport\Middleware\MiddlewareInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; + +// Create custom middleware +class AuthMiddleware implements MiddlewareInterface +{ + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + // Add auth token to all requests + $request = $request->withHeader('Authorization', 'Bearer ' . $this->getToken()); + + return $next($request); + } + + private function getToken(): string + { + // Your token logic + return 'your-token'; + } +} + +// Add to transport +$transport = TransportBuilder::make() + ->withMiddleware(new AuthMiddleware()) + ->build(); +``` + +### Configuration Options + +```php +$transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->withHeaders(['Accept' => 'application/json']) + ->withTimeout(30) // Seconds + ->withRetries(3) // Max retry attempts + ->withMiddleware($customMiddleware) + ->withoutDefaultMiddlewares() // Disable logging, timeout, retry middlewares + ->setClient($customPsrClient) // Use custom PSR-18 client + ->setLogger($customLogger) // Use custom PSR-3 logger + ->build(); +``` + +### HTTP Client Selection + +```php +use Farzai\Transport\Factory\ClientFactory; + +// Auto-detect (recommended - uses Symfony > Guzzle > Others) +$transport = TransportBuilder::make()->build(); + +// Explicitly use Guzzle +$transport = TransportBuilder::make() + ->setClient(ClientFactory::createGuzzle(['timeout' => 30])) + ->build(); + +// Explicitly use Symfony HTTP Client +$transport = TransportBuilder::make() + ->setClient(ClientFactory::createSymfony(['max_redirects' => 5])) + ->build(); + +// Check which client is being used +echo ClientFactory::getDetectedClientName(); // e.g., "Symfony\Component\HttpClient\Psr18Client" +``` + +### Error Handling + +```php +use Farzai\Transport\Exceptions\ClientException; +use Farzai\Transport\Exceptions\ServerException; +use Farzai\Transport\Exceptions\RetryExhaustedException; +use Farzai\Transport\Exceptions\JsonParseException; + +// Throw exception on non-2xx responses +try { + $response->throw(); +} catch (ClientException $e) { + // 4xx errors + echo "Client error: {$e->getStatusCode()}\n"; + echo "Request: {$e->getRequest()->getUri()}\n"; + var_dump($e->getContext()); // Rich debugging context +} catch (ServerException $e) { + // 5xx errors + echo "Server error: {$e->getStatusCode()}\n"; +} + +// Custom error handling callback +$response->throw(function ($response, $exception) { + if ($response->statusCode() === 404) { + throw new \Exception('Resource not found!'); + } + throw $exception; +}); + +// Handle retry exhaustion +try { + $response = $transport->get('/flaky-endpoint')->send(); +} catch (RetryExhaustedException $e) { + echo "Failed after {$e->getAttempts()} attempts\n"; + echo "Delays used: " . implode(', ', $e->getDelaysUsed()) . "ms\n"; + + foreach ($e->getRetryExceptions() as $attempt => $exception) { + echo "Attempt $attempt: {$exception->getMessage()}\n"; + } +} + +// Handle JSON parse errors +try { + $data = $response->json(); +} catch (JsonParseException $e) { + echo "Invalid JSON: {$e->getMessage()}\n"; + echo "JSON string: {$e->jsonString}\n"; + echo "Error code: {$e->jsonErrorCode}\n"; +} +``` + +### Using Custom PSR-18 Client and Logger + +```php +use Farzai\Transport\TransportBuilder; +use Monolog\Logger; +use Monolog\Handler\StreamHandler; + +// Create custom logger +$logger = new Logger('http-client'); +$logger->pushHandler(new StreamHandler('path/to/your.log')); + +// Use any PSR-18 compliant client +$client = new \Your\Custom\Psr18Client(); + +$transport = TransportBuilder::make() + ->setClient($client) + ->setLogger($logger) + ->build(); +``` + +### Testing with Response Builder + +```php +use Farzai\Transport\ResponseBuilder; + +$response = ResponseBuilder::create() + ->statusCode(200) + ->withHeader('Content-Type', 'application/json') + ->withBody('{"success": true}') + ->build(); + +// Or use the fluent builder methods +$response = ResponseBuilder::create() + ->statusCode(404) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Request-ID' => '12345' + ]) + ->withBody('{"error": "Not found"}') + ->withVersion('1.1') + ->withReason('Not Found') + ->build(); +``` + +## Documentation + +- **[Architecture Guide](docs/architecture.md)** - Deep dive into design patterns and internal architecture +- **[Migration Guide](docs/migration-from-guzzle.md)** - Migrating from Guzzle or v1.x +- **[Examples](examples/)** - Practical usage examples: + - [Basic Usage](examples/basic-usage.php) + - [Custom HTTP Clients](examples/custom-client.php) + - [Advanced Retry Logic](examples/advanced-retry.php) + - [Custom Middleware](examples/middleware-example.php) + +## Architecture + +### No Hard Dependencies on Guzzle + +Transport PHP v2.x uses PSR standards and auto-detection: + +- **PSR-7** - HTTP Message Interface +- **PSR-17** - HTTP Factories for creating requests/responses +- **PSR-18** - HTTP Client Interface + +This means you can use **any** PSR-18 compliant HTTP client: +- ✅ Symfony HTTP Client (modern, async, HTTP/2) +- ✅ Guzzle (popular, stable) +- ✅ Any custom PSR-18 implementation + +### Middleware System + +``` +Request → Middleware Stack → HTTP Client → Response + ↓ + [LoggingMiddleware] + [TimeoutMiddleware] + [RetryMiddleware] + [CustomMiddleware...] +``` + +Default middlewares (can be disabled with `withoutDefaultMiddlewares()`): +- **LoggingMiddleware**: Logs requests and responses +- **TimeoutMiddleware**: Enforces request timeouts +- **RetryMiddleware**: Handles retry logic with configurable strategies + +### Immutable Configuration + +All configuration is immutable and set during the build phase: + +```php +// ✅ Correct - configuration during build +$transport = TransportBuilder::make() + ->withTimeout(30) + ->withRetries(3) + ->build(); +``` + +This makes the Transport instance: +- **Thread-safe** - Can be safely shared across threads +- **Predictable** - Configuration can't change unexpectedly +- **Easier to test** - No hidden state changes + ## Testing ```bash composer test ``` +## Code Quality + +```bash +# Run tests with coverage +composer test-coverage + +# Fix code style +composer format +``` + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. diff --git a/composer.json b/composer.json index 988ab19..2471c5d 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "farzai/transport", - "description": "A HTTP client for Farzai Package", + "description": "A HTTP client for Farzai Package", "keywords": [ "farzai", "transport" @@ -16,15 +16,24 @@ "require": { "php": "^8.1", "farzai/support": "^1.2", - "guzzlehttp/guzzle": "^7.8", - "guzzlehttp/psr7": "^2.5", + "nyholm/psr7": "^1.8", + "php-http/discovery": "^1.19", "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", "psr/http-message": "^2.0" }, "require-dev": { + "guzzlehttp/guzzle": "^7.8", "laravel/pint": "^1.2", - "pestphp/pest": "^1.20", - "spatie/ray": "^1.28" + "mockery/mockery": "^1.6", + "pestphp/pest": "^2.30", + "phpstan/phpstan": "^1.10", + "spatie/ray": "^1.28", + "symfony/http-client": "^6.0|^7.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Popular HTTP client implementation with extensive features", + "symfony/http-client": "Modern HTTP client with async support and HTTP/2" }, "autoload": { "psr-4": { @@ -45,6 +54,7 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, + "php-http/discovery": true, "phpstan/extension-installer": true } }, diff --git a/examples/advanced-retry.php b/examples/advanced-retry.php new file mode 100644 index 0000000..22e94f5 --- /dev/null +++ b/examples/advanced-retry.php @@ -0,0 +1,193 @@ +withBaseUri('https://jsonplaceholder.typicode.com') + ->withRetries( + maxRetries: 3, + strategy: new ExponentialBackoffStrategy( + baseDelayMs: 1000, // Start with 1 second + multiplier: 2.0, // Double each retry + maxDelayMs: 30000, // Cap at 30 seconds + useJitter: true // Add randomization to prevent thundering herd + ) + ) + ->build(); + +echo "Configured with:\n"; +echo " - Max retries: 3\n"; +echo " - Base delay: 1000ms\n"; +echo " - Multiplier: 2.0\n"; +echo " - Max delay: 30000ms\n"; +echo " - Jitter: enabled\n\n"; + +// Example 2: Fixed Delay Strategy +echo "2. Fixed Delay Strategy\n"; +echo str_repeat('-', 50)."\n"; + +$transport2 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withRetries( + maxRetries: 3, + strategy: new FixedDelayStrategy(delayMs: 2000) // Wait 2 seconds between retries + ) + ->build(); + +echo "Configured with:\n"; +echo " - Max retries: 3\n"; +echo " - Fixed delay: 2000ms\n\n"; + +// Example 3: Custom Retry Conditions +echo "3. Custom Retry Conditions\n"; +echo str_repeat('-', 50)."\n"; + +$transport3 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withRetries( + maxRetries: 5, + strategy: new ExponentialBackoffStrategy, + condition: RetryCondition::default() + ->onStatusCodes([408, 429, 500, 502, 503, 504]) // Retry on specific status codes + ) + ->build(); + +echo "Configured to retry on:\n"; +echo " - 408 Request Timeout\n"; +echo " - 429 Too Many Requests\n"; +echo " - 500 Internal Server Error\n"; +echo " - 502 Bad Gateway\n"; +echo " - 503 Service Unavailable\n"; +echo " - 504 Gateway Timeout\n\n"; + +// Example 4: Retry with Custom Condition Callback +echo "4. Retry with Custom Condition Callback\n"; +echo str_repeat('-', 50)."\n"; + +$transport4 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withRetries( + maxRetries: 3, + strategy: new ExponentialBackoffStrategy, + condition: RetryCondition::fromCallback( + function (\Throwable $exception, \Farzai\Transport\Retry\RetryContext $context): bool { + echo " Retry attempt {$context->attempt}/{$context->maxAttempts}\n"; + echo " Exception: {$exception->getMessage()}\n"; + + // Retry only on network errors or 5xx responses + if ($exception instanceof \Farzai\Transport\Exceptions\ServerException) { + echo " Decision: RETRY (Server error)\n\n"; + + return true; + } + + if ($exception instanceof \Farzai\Transport\Exceptions\NetworkException) { + echo " Decision: RETRY (Network error)\n\n"; + + return true; + } + + echo " Decision: DO NOT RETRY\n\n"; + + return false; + } + ) + ) + ->build(); + +echo "Custom retry logic:\n"; +echo " - Retry on ServerException (5xx)\n"; +echo " - Retry on NetworkException\n"; +echo " - Do not retry on ClientException (4xx)\n\n"; + +// Example 5: Handling Retry Exhaustion +echo "5. Handling Retry Exhaustion\n"; +echo str_repeat('-', 50)."\n"; + +try { + // This will likely succeed, but demonstrates the pattern + $response = $transport->get('/posts/1')->send(); + echo "Request succeeded on first try\n"; + echo "Title: {$response->json('title')}\n\n"; +} catch (RetryExhaustedException $e) { + echo "❌ Request failed after {$e->getAttempts()} attempts\n"; + echo "Last error: {$e->getMessage()}\n\n"; + + echo "Retry details:\n"; + foreach ($e->getRetryExceptions() as $attempt => $exception) { + echo " Attempt {$attempt}: {$exception->getMessage()}\n"; + } + echo "\n"; + + echo 'Delays used: '.implode(', ', $e->getDelaysUsed())." ms\n\n"; +} + +// Example 6: Demonstrating Retry with Valid Request +echo "6. Successful Request (No Retries Needed)\n"; +echo str_repeat('-', 50)."\n"; + +try { + $startTime = microtime(true); + + $response = $transport->get('/posts/1')->send(); + + $duration = round((microtime(true) - $startTime) * 1000, 2); + + echo "✓ Request succeeded\n"; + echo " Duration: {$duration}ms\n"; + echo " Status: {$response->statusCode()}\n"; + echo " Title: {$response->json('title')}\n\n"; +} catch (RetryExhaustedException $e) { + echo "❌ Failed after {$e->getAttempts()} attempts\n\n"; +} + +// Example 7: Retry Strategy Comparison +echo "7. Retry Strategy Comparison\n"; +echo str_repeat('-', 50)."\n"; + +echo "Exponential Backoff delays (base=1000ms, multiplier=2.0):\n"; +$exponential = new ExponentialBackoffStrategy(1000, 2.0, 30000, false); +for ($i = 0; $i < 5; $i++) { + $context = new \Farzai\Transport\Retry\RetryContext( + request: $transport->request()->uri('/test')->build(), + attempt: $i, + maxAttempts: 5 + ); + $delay = $exponential->getDelay($context); + echo " Attempt {$i}: {$delay}ms\n"; +} +echo "\n"; + +echo "Fixed Delay (2000ms):\n"; +$fixed = new FixedDelayStrategy(2000); +for ($i = 0; $i < 5; $i++) { + $context = new \Farzai\Transport\Retry\RetryContext( + request: $transport->request()->uri('/test')->build(), + attempt: $i, + maxAttempts: 5 + ); + $delay = $fixed->getDelay($context); + echo " Attempt {$i}: {$delay}ms\n"; +} +echo "\n"; + +echo "=== Examples Complete ===\n"; diff --git a/examples/basic-usage.php b/examples/basic-usage.php new file mode 100644 index 0000000..a2ea7d6 --- /dev/null +++ b/examples/basic-usage.php @@ -0,0 +1,168 @@ +withBaseUri('https://jsonplaceholder.typicode.com') + ->withHeaders([ + 'Accept' => 'application/json', + 'User-Agent' => 'Transport-PHP-Example/1.0', + ]) + ->withTimeout(30) + ->build(); + +echo "=== Transport PHP Basic Usage Examples ===\n\n"; + +// Example 1: Simple GET Request +echo "1. Simple GET Request\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->get('/posts/1')->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Title: {$response->json('title')}\n"; + echo "Body: {$response->json('body')}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 2: GET Request with Query Parameters +echo "2. GET Request with Query Parameters\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->get('/posts') + ->withQuery([ + 'userId' => 1, + '_limit' => 3, + ]) + ->send(); + + $posts = $response->json(); + echo 'Found '.($posts ? count($posts) : 0)." posts:\n"; + + foreach ($posts as $post) { + echo " - [{$post['id']}] {$post['title']}\n"; + } + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 3: POST Request with JSON Body +echo "3. POST Request with JSON Body\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->post('/posts') + ->withJson([ + 'title' => 'My New Post', + 'body' => 'This is the content of my new post.', + 'userId' => 1, + ]) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Created Post ID: {$response->json('id')}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 4: PUT Request (Update) +echo "4. PUT Request (Update)\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->put('/posts/1') + ->withJson([ + 'id' => 1, + 'title' => 'Updated Title', + 'body' => 'Updated body content.', + 'userId' => 1, + ]) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Updated Title: {$response->json('title')}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 5: DELETE Request +echo "5. DELETE Request\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->delete('/posts/1')->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Successfully deleted\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 6: Handling Non-2xx Responses +echo "6. Handling Non-2xx Responses\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->get('/posts/9999')->send(); + + if ($response->isSuccessful()) { + echo "Post found: {$response->json('title')}\n\n"; + } else { + echo "Post not found (Status: {$response->statusCode()})\n\n"; + } +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 7: Safe JSON Parsing +echo "7. Safe JSON Parsing with jsonOrNull()\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->get('/posts/1')->send(); + + // This won't throw exception even if JSON is invalid + $data = $response->jsonOrNull(); + + if ($data !== null) { + echo "Post title: {$data['title']}\n\n"; + } else { + echo "Invalid JSON response\n\n"; + } +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 8: Working with Response Headers +echo "8. Working with Response Headers\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->get('/posts/1')->send(); + + echo 'Content-Type: '.implode(', ', $response->getHeader('Content-Type'))."\n"; + echo "All headers:\n"; + + foreach ($response->headers() as $name => $values) { + echo " {$name}: ".implode(', ', $values)."\n"; + } + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +echo "=== Examples Complete ===\n"; diff --git a/examples/custom-client.php b/examples/custom-client.php new file mode 100644 index 0000000..b1fd736 --- /dev/null +++ b/examples/custom-client.php @@ -0,0 +1,143 @@ +withBaseUri('https://jsonplaceholder.typicode.com') + ->build(); + + $response = $transport->get('/posts/1')->send(); + echo "Successfully fetched post using {$clientName}\n"; + echo "Title: {$response->json('title')}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 2: Explicitly using Guzzle (if available) +echo "2. Using Guzzle HTTP Client\n"; +echo str_repeat('-', 50)."\n"; + +if (ClientFactory::isAvailable('guzzle')) { + try { + $guzzleClient = ClientFactory::createGuzzle([ + 'timeout' => 30, + 'verify' => true, + ]); + + $transport = TransportBuilder::make() + ->setClient($guzzleClient) + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->build(); + + $response = $transport->get('/posts/1')->send(); + echo "Successfully fetched post using Guzzle\n"; + echo "Title: {$response->json('title')}\n\n"; + } catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; + } +} else { + echo "Guzzle is not installed\n"; + echo "Install it with: composer require guzzlehttp/guzzle\n\n"; +} + +// Example 3: Explicitly using Symfony HTTP Client (if available) +echo "3. Using Symfony HTTP Client\n"; +echo str_repeat('-', 50)."\n"; + +if (ClientFactory::isAvailable('symfony')) { + try { + $symfonyClient = ClientFactory::createSymfony([ + 'timeout' => 30, + 'max_redirects' => 5, + ]); + + $transport = TransportBuilder::make() + ->setClient($symfonyClient) + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->build(); + + $response = $transport->get('/posts/1')->send(); + echo "Successfully fetched post using Symfony HTTP Client\n"; + echo "Title: {$response->json('title')}\n\n"; + } catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; + } +} else { + echo "Symfony HTTP Client is not installed\n"; + echo "Install it with: composer require symfony/http-client\n\n"; +} + +// Example 4: Checking Available Clients +echo "4. Checking Available HTTP Clients\n"; +echo str_repeat('-', 50)."\n"; + +$clients = [ + 'guzzle' => 'GuzzleHTTP\\Client', + 'symfony' => 'Symfony\\Component\\HttpClient\\Psr18Client', +]; + +echo "Available HTTP clients:\n"; +foreach ($clients as $name => $class) { + $available = ClientFactory::isAvailable($name); + $status = $available ? '✓ Available' : '✗ Not installed'; + echo " {$name}: {$status}\n"; + + if (! $available) { + echo ' Install with: composer require '; + echo $name === 'guzzle' ? 'guzzlehttp/guzzle' : 'symfony/http-client'; + echo "\n"; + } +} +echo "\n"; + +// Example 5: Using with Logger +echo "5. HTTP Client with Logging\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create a simple logger + $logger = new class extends \Psr\Log\AbstractLogger + { + public function log($level, $message, array $context = []): void + { + $contextStr = ! empty($context) ? ' '.json_encode($context) : ''; + echo "[{$level}] {$message}{$contextStr}\n"; + } + }; + + // Create client with logger (logs which client was detected) + $client = ClientFactory::create($logger); + + $transport = TransportBuilder::make() + ->setClient($client) + ->setLogger($logger) + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->build(); + + $response = $transport->get('/posts/1')->send(); + echo "\nTitle: {$response->json('title')}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +echo "=== Examples Complete ===\n"; diff --git a/examples/middleware-example.php b/examples/middleware-example.php new file mode 100644 index 0000000..45702bc --- /dev/null +++ b/examples/middleware-example.php @@ -0,0 +1,281 @@ +withHeader($this->headerName, $this->headerValue); + + return $next($request); + } +} + +$transport = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withMiddleware(new CustomHeaderMiddleware('X-Custom-Header', 'my-value')) + ->build(); + +try { + $response = $transport->get('/posts/1')->send(); + echo "✓ Request completed with custom header\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 2: Request/Response Logging Middleware +echo "2. Request/Response Logging Middleware\n"; +echo str_repeat('-', 50)."\n"; + +class DetailedLoggingMiddleware implements MiddlewareInterface +{ + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $startTime = microtime(true); + + echo "→ Request:\n"; + echo " Method: {$request->getMethod()}\n"; + echo " URI: {$request->getUri()}\n"; + echo " Headers:\n"; + foreach ($request->getHeaders() as $name => $values) { + echo " {$name}: ".implode(', ', $values)."\n"; + } + + // Execute request + $response = $next($request); + + $duration = round((microtime(true) - $startTime) * 1000, 2); + + echo "\n← Response:\n"; + echo " Status: {$response->getStatusCode()}\n"; + echo " Duration: {$duration}ms\n"; + echo " Headers:\n"; + foreach ($response->getHeaders() as $name => $values) { + echo " {$name}: ".implode(', ', $values)."\n"; + } + echo "\n"; + + return $response; + } +} + +$transport2 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withoutDefaultMiddlewares() // Disable default logging + ->withMiddleware(new DetailedLoggingMiddleware) + ->build(); + +try { + $response = $transport2->get('/posts/1')->send(); +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 3: Authentication Middleware +echo "3. API Key Authentication Middleware\n"; +echo str_repeat('-', 50)."\n"; + +class ApiKeyAuthMiddleware implements MiddlewareInterface +{ + public function __construct( + private readonly string $apiKey, + private readonly string $headerName = 'X-API-Key' + ) {} + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + // Add API key to all requests + $request = $request->withHeader($this->headerName, $this->apiKey); + + return $next($request); + } +} + +$transport3 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withMiddleware(new ApiKeyAuthMiddleware('your-api-key-here')) + ->build(); + +try { + $response = $transport3->get('/posts/1')->send(); + echo "✓ Request completed with API key authentication\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 4: Response Caching Middleware +echo "4. Simple Response Caching Middleware\n"; +echo str_repeat('-', 50)."\n"; + +class SimpleCacheMiddleware implements MiddlewareInterface +{ + private array $cache = []; + + public function __construct( + private readonly int $ttlSeconds = 60 + ) {} + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + // Only cache GET requests + if ($request->getMethod() !== 'GET') { + return $next($request); + } + + $cacheKey = (string) $request->getUri(); + + // Check cache + if (isset($this->cache[$cacheKey])) { + $cached = $this->cache[$cacheKey]; + + if (time() - $cached['time'] < $this->ttlSeconds) { + echo " ✓ Cache HIT for: {$cacheKey}\n\n"; + + return $cached['response']; + } + + unset($this->cache[$cacheKey]); + } + + echo " ✗ Cache MISS for: {$cacheKey}\n"; + $response = $next($request); + + // Store in cache + $this->cache[$cacheKey] = [ + 'response' => $response, + 'time' => time(), + ]; + echo " ✓ Cached response\n\n"; + + return $response; + } +} + +$cacheMiddleware = new SimpleCacheMiddleware(ttlSeconds: 60); + +$transport4 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withMiddleware($cacheMiddleware) + ->build(); + +try { + echo "First request (should MISS cache):\n"; + $response1 = $transport4->get('/posts/1')->send(); + + echo "Second request (should HIT cache):\n"; + $response2 = $transport4->get('/posts/1')->send(); +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 5: Rate Limiting Middleware +echo "5. Simple Rate Limiting Middleware\n"; +echo str_repeat('-', 50)."\n"; + +class RateLimitMiddleware implements MiddlewareInterface +{ + private array $requests = []; + + public function __construct( + private readonly int $maxRequests = 10, + private readonly int $perSeconds = 60 + ) {} + + public function handle(RequestInterface $request, callable $next): ResponseInterface + { + $now = time(); + + // Clean old requests + $this->requests = array_filter( + $this->requests, + fn ($timestamp) => $now - $timestamp < $this->perSeconds + ); + + // Check rate limit + if (count($this->requests) >= $this->maxRequests) { + echo " ⚠ Rate limit exceeded ({$this->maxRequests} requests per {$this->perSeconds}s)\n"; + echo " Waiting...\n\n"; + + // Wait until we can make the next request + $oldestRequest = min($this->requests); + $waitTime = $this->perSeconds - ($now - $oldestRequest) + 1; + sleep($waitTime); + + // Clean again after waiting + $now = time(); + $this->requests = array_filter( + $this->requests, + fn ($timestamp) => $now - $timestamp < $this->perSeconds + ); + } + + // Record this request + $this->requests[] = $now; + + echo ' ✓ Rate limit OK ('.count($this->requests)."/{$this->maxRequests} requests)\n\n"; + + return $next($request); + } +} + +$transport5 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withMiddleware(new RateLimitMiddleware(maxRequests: 3, perSeconds: 10)) + ->build(); + +try { + echo "Making 4 requests (rate limit: 3/10s):\n\n"; + + for ($i = 1; $i <= 4; $i++) { + echo "Request {$i}:\n"; + $response = $transport5->get('/posts/1')->send(); + } +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 6: Chaining Multiple Middlewares +echo "6. Chaining Multiple Middlewares\n"; +echo str_repeat('-', 50)."\n"; + +$transport6 = TransportBuilder::make() + ->withBaseUri('https://jsonplaceholder.typicode.com') + ->withMiddleware(new ApiKeyAuthMiddleware('test-key')) + ->withMiddleware(new CustomHeaderMiddleware('X-Request-ID', uniqid())) + ->build(); + +try { + $response = $transport6->get('/posts/1')->send(); + echo "✓ Request completed with multiple middlewares:\n"; + echo " - API Key Authentication\n"; + echo " - Custom Request ID Header\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +echo "=== Examples Complete ===\n"; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index c5aaa44..c2ab1dc 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,39 +1,23 @@ - - - - tests - - - - - ./src - - - - - - - - - - + + + + tests + + + + + + + + + + + + + + + ./src + + diff --git a/src/Contracts/ClientInterface.php b/src/Contracts/ClientInterface.php deleted file mode 100644 index 579685c..0000000 --- a/src/Contracts/ClientInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - + * + * @throws \Farzai\Transport\Exceptions\JsonParseException + */ + public function toArray(): array; + + /** + * Throw an exception if the response is not successful. * * @param callable|null $callback Custom callback to throw an exception. * diff --git a/src/Contracts/SerializerInterface.php b/src/Contracts/SerializerInterface.php new file mode 100644 index 0000000..61ee716 --- /dev/null +++ b/src/Contracts/SerializerInterface.php @@ -0,0 +1,54 @@ +get('/api/endpoint')->send()->throw(); + * } catch (BadResponseException $e) { + * // Handle bad response (any non-2xx status) + * echo "Bad response: " . $e->getStatusCode(); + * } + * ``` + */ +class BadResponseException extends HttpException +{ + // +} diff --git a/src/Exceptions/ClientException.php b/src/Exceptions/ClientException.php index 37d12bb..20026f5 100644 --- a/src/Exceptions/ClientException.php +++ b/src/Exceptions/ClientException.php @@ -1,10 +1,38 @@ get('/api/users/999')->send()->throw(); + * } catch (ClientException $e) { + * if ($e->getStatusCode() === 404) { + * // Handle not found + * } elseif ($e->getStatusCode() === 401) { + * // Handle unauthorized + * } + * } + * ``` + */ +class ClientException extends BadResponseException { // } diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php new file mode 100644 index 0000000..0c0ae96 --- /dev/null +++ b/src/Exceptions/HttpException.php @@ -0,0 +1,124 @@ +get('/api/endpoint')->send(); + * } catch (HttpException $e) { + * echo "HTTP Error: " . $e->getMessage(); + * echo "Request: " . $e->getRequest()->getUri(); + * if ($e->hasResponse()) { + * echo "Status: " . $e->getResponse()->getStatusCode(); + * } + * } + * ``` + */ +class HttpException extends RuntimeException implements RequestExceptionInterface +{ + /** + * Create a new HTTP exception. + * + * @param string $message Error message + * @param RequestInterface $request The request that caused the exception + * @param ResponseInterface|null $response The response (if available) + * @param Throwable|null $previous Previous exception + * @param int $code Error code + */ + public function __construct( + string $message, + protected readonly RequestInterface $request, + protected readonly ?ResponseInterface $response = null, + ?Throwable $previous = null, + int $code = 0 + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get the request that caused the exception. + * + * @return RequestInterface The HTTP request + */ + public function getRequest(): RequestInterface + { + return $this->request; + } + + /** + * Get the response if available. + * + * @return ResponseInterface|null The HTTP response or null + */ + public function getResponse(): ?ResponseInterface + { + return $this->response; + } + + /** + * Check if a response is available. + * + * @return bool True if response is available + */ + public function hasResponse(): bool + { + return $this->response !== null; + } + + /** + * Get the HTTP status code if response is available. + * + * @return int|null The status code or null + */ + public function getStatusCode(): ?int + { + return $this->response?->getStatusCode(); + } + + /** + * Get a detailed error context for logging. + * + * @return array Error context + */ + public function getContext(): array + { + $context = [ + 'message' => $this->getMessage(), + 'request' => [ + 'method' => $this->request->getMethod(), + 'uri' => (string) $this->request->getUri(), + 'headers' => $this->request->getHeaders(), + ], + ]; + + if ($this->hasResponse()) { + $context['response'] = [ + 'status_code' => $this->response->getStatusCode(), + 'reason_phrase' => $this->response->getReasonPhrase(), + 'headers' => $this->response->getHeaders(), + ]; + } + + return $context; + } +} diff --git a/src/Exceptions/JsonEncodeException.php b/src/Exceptions/JsonEncodeException.php new file mode 100644 index 0000000..fbe8cd9 --- /dev/null +++ b/src/Exceptions/JsonEncodeException.php @@ -0,0 +1,64 @@ +getMessage()), + value: $value, + jsonErrorCode: $exception->getCode(), + jsonErrorMessage: $exception->getMessage(), + depth: $depth, + previous: $exception + ); + } +} diff --git a/src/Exceptions/JsonParseException.php b/src/Exceptions/JsonParseException.php new file mode 100644 index 0000000..b6d37ee --- /dev/null +++ b/src/Exceptions/JsonParseException.php @@ -0,0 +1,63 @@ +getMessage()), + jsonString: $jsonString, + jsonErrorCode: $exception->getCode(), + jsonErrorMessage: $exception->getMessage(), + depth: $depth, + previous: $exception + ); + } +} diff --git a/src/Exceptions/MaxRetriesExceededException.php b/src/Exceptions/MaxRetriesExceededException.php deleted file mode 100644 index 7804c14..0000000 --- a/src/Exceptions/MaxRetriesExceededException.php +++ /dev/null @@ -1,8 +0,0 @@ -isSuccessful()) { + * throw ResponseExceptionFactory::create($response); + * } + * ``` + */ class ResponseExceptionFactory { /** - * Create a new exception instance. + * Create an appropriate exception instance based on the response. + * + * Exception types: + * - ClientException: 4xx status codes (client errors) + * - ServerException: 5xx status codes (server errors) + * - BadResponseException: Other non-2xx status codes + * + * @param ResponseInterface $response The HTTP response + * @param Throwable|null $previous Previous exception in the chain + * @return HttpException The created exception + * + * @example + * ```php + * $exception = ResponseExceptionFactory::create($response); + * // Returns ClientException for 404, ServerException for 500, etc. + * ``` */ - public static function create(ResponseInterface $response, ?Throwable $previous = null) + public static function create(ResponseInterface $response, ?Throwable $previous = null): HttpException { $statusCode = $response->statusCode(); + $message = static::getErrorMessage($response); + $request = $response->getPsrRequest(); + // 4xx Client Errors if ($statusCode >= 400 && $statusCode < 500) { - return new BadResponseException( - static::getErrorMessage($response), - $response->getPsrRequest(), + return new ClientException( + $message, + $request, $response, $previous, + $statusCode ); } + // 5xx Server Errors if ($statusCode >= 500 && $statusCode < 600) { return new ServerException( - static::getErrorMessage($response), - $response->getPsrRequest(), + $message, + $request, $response, $previous, + $statusCode ); } - return new ClientException( - static::getErrorMessage($response), - $response->getPsrRequest(), + // Other non-2xx responses + return new BadResponseException( + $message, + $request, $response, $previous, + $statusCode ); } /** - * Get the error message from the response. + * Extract a meaningful error message from the response. + * + * This method attempts to extract error messages from common + * response formats (JSON APIs, HTML, plain text) in order of preference: + * + * 1. JSON error fields (message, error, error_description, etc.) + * 2. Raw response body + * 3. HTTP status code description + * + * @param ResponseInterface $response The HTTP response + * @return string The extracted error message + * + * @example + * ```php + * // JSON API: {"error": "User not found"} + * $message = ResponseExceptionFactory::getErrorMessage($response); + * // Returns: "User not found" + * + * // Plain text response + * $message = ResponseExceptionFactory::getErrorMessage($response); + * // Returns: "404 Not Found" + * ``` */ public static function getErrorMessage(ResponseInterface $response): string { - $message = $response->body(); - - if ($response->json() !== null) { - return $response->json('message') - ?: $response->json('error_message') - ?: $response->json('error_msg') - ?: $response->json('error_description') - ?: $response->json('error') - ?: 'Unknown error'; + $statusCode = $response->statusCode(); + + // Try to extract error from JSON response + $jsonError = static::extractJsonError($response); + if ($jsonError !== null) { + return $jsonError; + } + + // Fall back to response body (truncated if too long) + $body = $response->body(); + if (! empty($body) && strlen($body) < 500) { + return $body; } - return $message; + // Fall back to HTTP status code description + return sprintf( + 'HTTP %d %s', + $statusCode, + $response->getReasonPhrase() + ); + } + + /** + * Try to extract error message from JSON response. + * + * Checks common JSON error field names: + * - message + * - error + * - error_message + * - error_msg + * - error_description + * - errors (if array, joins them) + * + * @param ResponseInterface $response The HTTP response + * @return string|null The error message or null if not JSON + */ + private static function extractJsonError(ResponseInterface $response): ?string + { + try { + $json = $response->jsonOrNull(); + + if (! is_array($json)) { + return null; + } + + // Common error field names in order of preference + $errorFields = [ + 'message', + 'error_message', + 'error_msg', + 'error_description', + 'error', + 'errors', + ]; + + foreach ($errorFields as $field) { + if (isset($json[$field])) { + $value = $json[$field]; + + // Handle array of errors + if (is_array($value)) { + return implode('; ', array_filter($value)); + } + + // Handle string error + if (is_string($value) && ! empty($value)) { + return $value; + } + } + } + + return null; + } catch (\Throwable) { + // Not a JSON response or invalid JSON + return null; + } } } diff --git a/src/Exceptions/RetryExhaustedException.php b/src/Exceptions/RetryExhaustedException.php new file mode 100644 index 0000000..c610727 --- /dev/null +++ b/src/Exceptions/RetryExhaustedException.php @@ -0,0 +1,47 @@ + + */ + public function getRetryExceptions(): array + { + return $this->context->exceptions; + } + + /** + * Get the delays that were used between retries. + * + * @return array + */ + public function getDelaysUsed(): array + { + return $this->context->delaysUsed; + } + + /** + * Get the number of attempts made. + */ + public function getAttempts(): int + { + return $this->context->attempt; + } +} diff --git a/src/Exceptions/SerializationException.php b/src/Exceptions/SerializationException.php new file mode 100644 index 0000000..111624d --- /dev/null +++ b/src/Exceptions/SerializationException.php @@ -0,0 +1,52 @@ +data === null) { + return null; + } + + if (strlen($this->data) <= $maxLength) { + return $this->data; + } + + return substr($this->data, 0, $maxLength).' ... (truncated)'; + } +} diff --git a/src/Exceptions/ServerException.php b/src/Exceptions/ServerException.php new file mode 100644 index 0000000..33fb2c1 --- /dev/null +++ b/src/Exceptions/ServerException.php @@ -0,0 +1,40 @@ +get('/api/endpoint')->send()->throw(); + * } catch (ServerException $e) { + * // Log server error for monitoring + * $logger->error('Server error', $e->getContext()); + * + * // Maybe retry or show user-friendly message + * if ($e->getStatusCode() === 503) { + * echo "Service temporarily unavailable. Please try again later."; + * } + * } + * ``` + */ +class ServerException extends BadResponseException +{ + // +} diff --git a/src/Exceptions/TimeoutException.php b/src/Exceptions/TimeoutException.php new file mode 100644 index 0000000..39d5539 --- /dev/null +++ b/src/Exceptions/TimeoutException.php @@ -0,0 +1,20 @@ +debug('ClientFactory: Using Symfony HTTP Client'); + + return new \Symfony\Component\HttpClient\Psr18Client; + } + + // Try Guzzle HTTP Client (popular, widely used) + if (class_exists('GuzzleHttp\Client')) { + $logger->debug('ClientFactory: Using Guzzle HTTP Client'); + + return new \GuzzleHttp\Client; + } + + // Fallback to PSR-18 discovery (will find any installed PSR-18 client) + try { + $client = Psr18ClientDiscovery::find(); + $logger->debug('ClientFactory: Using auto-detected PSR-18 client', [ + 'client' => get_class($client), + ]); + + return $client; + } catch (\Throwable $e) { + $logger->error('ClientFactory: No PSR-18 HTTP client found', [ + 'error' => $e->getMessage(), + ]); + + throw new \RuntimeException( + 'No PSR-18 HTTP client implementation found. '. + 'Please install one: composer require guzzlehttp/guzzle or composer require symfony/http-client', + 0, + $e + ); + } + } + + /** + * Create a Guzzle HTTP client specifically. + * + * @param array $config Guzzle configuration options + * @return ClientInterface Guzzle HTTP client + * + * @throws \RuntimeException If Guzzle is not installed + * + * @example + * ```php + * $client = ClientFactory::createGuzzle([ + * 'timeout' => 30, + * 'verify' => true, + * ]); + * ``` + */ + public static function createGuzzle(array $config = []): ClientInterface + { + if (! class_exists('GuzzleHttp\Client')) { + throw new \RuntimeException( + 'Guzzle HTTP client is not installed. Install it with: composer require guzzlehttp/guzzle' + ); + } + + return new \GuzzleHttp\Client($config); + } + + /** + * Create a Symfony HTTP client specifically. + * + * @param array $options Symfony HTTP client options + * @return ClientInterface Symfony HTTP client + * + * @throws \RuntimeException If Symfony HTTP Client is not installed + * + * @example + * ```php + * $client = ClientFactory::createSymfony([ + * 'timeout' => 30, + * 'max_redirects' => 5, + * ]); + * ``` + */ + public static function createSymfony(array $options = []): ClientInterface + { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + throw new \RuntimeException( + 'Symfony HTTP Client is not installed. Install it with: composer require symfony/http-client' + ); + } + + // If options are provided, create a configured HttpClient first + if (! empty($options)) { + $httpClient = \Symfony\Component\HttpClient\HttpClient::create($options); + + return new \Symfony\Component\HttpClient\Psr18Client($httpClient); + } + + return new \Symfony\Component\HttpClient\Psr18Client; + } + + /** + * Check if a specific client type is available. + * + * @param string $clientType Client type: 'guzzle', 'symfony', or FQCN + * @return bool True if the client is available + * + * @example + * ```php + * if (ClientFactory::isAvailable('guzzle')) { + * $client = ClientFactory::createGuzzle(); + * } + * ``` + */ + public static function isAvailable(string $clientType): bool + { + return match (strtolower($clientType)) { + 'guzzle' => class_exists('GuzzleHttp\Client'), + 'symfony' => class_exists('Symfony\Component\HttpClient\Psr18Client'), + default => class_exists($clientType), + }; + } + + /** + * Get the name of the auto-detected client. + * + * This is useful for debugging and logging purposes. + * + * @return string The class name of the detected client + * + * @throws \RuntimeException If no client is available + * + * @example + * ```php + * echo "Using client: " . ClientFactory::getDetectedClientName(); + * // Output: "Using client: GuzzleHttp\Client" + * ``` + */ + public static function getDetectedClientName(): string + { + try { + $client = self::create(); + + return get_class($client); + } catch (\Throwable $e) { + throw new \RuntimeException('No HTTP client could be detected', 0, $e); + } + } +} diff --git a/src/Factory/HttpFactory.php b/src/Factory/HttpFactory.php new file mode 100644 index 0000000..48d4e7a --- /dev/null +++ b/src/Factory/HttpFactory.php @@ -0,0 +1,243 @@ +createRequest('GET', 'https://api.example.com'); + * $uri = $factory->createUri('https://example.com'); + * ``` + */ +final class HttpFactory +{ + private static ?self $instance = null; + + /** + * Create a new HTTP factory instance. + * + * @param RequestFactoryInterface|null $requestFactory Custom request factory + * @param ResponseFactoryInterface|null $responseFactory Custom response factory + * @param UriFactoryInterface|null $uriFactory Custom URI factory + * @param StreamFactoryInterface|null $streamFactory Custom stream factory + */ + public function __construct( + private readonly ?RequestFactoryInterface $requestFactory = null, + private readonly ?ResponseFactoryInterface $responseFactory = null, + private readonly ?UriFactoryInterface $uriFactory = null, + private readonly ?StreamFactoryInterface $streamFactory = null + ) {} + + /** + * Get singleton instance with auto-detected factories. + * + * This method provides a convenient way to access a shared factory instance + * with automatically discovered PSR-17 implementations. + * + * @return self The singleton factory instance + */ + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self; + } + + return self::$instance; + } + + /** + * Reset the singleton instance (useful for testing). + * + * @internal This method is primarily for testing purposes + */ + public static function resetInstance(): void + { + self::$instance = null; + } + + /** + * Create a new PSR-7 request. + * + * @param string $method HTTP method (GET, POST, etc.) + * @param UriInterface|string $uri Request URI + * @return RequestInterface The created request + * + * @throws \RuntimeException If no request factory is available + * + * @example + * ```php + * $request = $factory->createRequest('POST', 'https://api.example.com/users'); + * ``` + */ + public function createRequest(string $method, UriInterface|string $uri): RequestInterface + { + return $this->getRequestFactory()->createRequest($method, $uri); + } + + /** + * Create a new PSR-7 response. + * + * @param int $code HTTP status code (default: 200) + * @param string $reasonPhrase Reason phrase (default: '') + * @return ResponseInterface The created response + * + * @throws \RuntimeException If no response factory is available + * + * @example + * ```php + * $response = $factory->createResponse(404, 'Not Found'); + * ``` + */ + public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface + { + return $this->getResponseFactory()->createResponse($code, $reasonPhrase); + } + + /** + * Create a new PSR-7 URI. + * + * @param string $uri URI string + * @return UriInterface The created URI + * + * @throws \RuntimeException If no URI factory is available + * @throws \InvalidArgumentException If URI is malformed + * + * @example + * ```php + * $uri = $factory->createUri('https://api.example.com/users?page=1'); + * ``` + */ + public function createUri(string $uri = ''): UriInterface + { + return $this->getUriFactory()->createUri($uri); + } + + /** + * Create a new PSR-7 stream from a string. + * + * @param string $content Stream content + * @return StreamInterface The created stream + * + * @throws \RuntimeException If no stream factory is available + * + * @example + * ```php + * $stream = $factory->createStream(json_encode(['key' => 'value'])); + * ``` + */ + public function createStream(string $content = ''): StreamInterface + { + return $this->getStreamFactory()->createStream($content); + } + + /** + * Create a stream from an existing resource. + * + * @param resource $resource PHP resource + * @return StreamInterface The created stream + * + * @throws \RuntimeException If no stream factory is available + * @throws \InvalidArgumentException If resource is invalid + */ + public function createStreamFromResource($resource): StreamInterface + { + return $this->getStreamFactory()->createStreamFromResource($resource); + } + + /** + * Create a stream from a file. + * + * @param string $filename Path to file + * @param string $mode File mode (default: 'r') + * @return StreamInterface The created stream + * + * @throws \RuntimeException If file cannot be opened or no stream factory is available + * + * @example + * ```php + * $stream = $factory->createStreamFromFile('/path/to/file.txt', 'r'); + * ``` + */ + public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface + { + return $this->getStreamFactory()->createStreamFromFile($filename, $mode); + } + + /** + * Get the request factory (auto-detected if not provided). + * + * @return RequestFactoryInterface The request factory + * + * @throws \RuntimeException If no request factory implementation is available + */ + private function getRequestFactory(): RequestFactoryInterface + { + return $this->requestFactory ?? Psr17FactoryDiscovery::findRequestFactory(); + } + + /** + * Get the response factory (auto-detected if not provided). + * + * @return ResponseFactoryInterface The response factory + * + * @throws \RuntimeException If no response factory implementation is available + */ + private function getResponseFactory(): ResponseFactoryInterface + { + return $this->responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + } + + /** + * Get the URI factory (auto-detected if not provided). + * + * @return UriFactoryInterface The URI factory + * + * @throws \RuntimeException If no URI factory implementation is available + */ + private function getUriFactory(): UriFactoryInterface + { + return $this->uriFactory ?? Psr17FactoryDiscovery::findUriFactory(); + } + + /** + * Get the stream factory (auto-detected if not provided). + * + * @return StreamFactoryInterface The stream factory + * + * @throws \RuntimeException If no stream factory implementation is available + */ + private function getStreamFactory(): StreamFactoryInterface + { + return $this->streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + } +} diff --git a/src/Middleware/LoggingMiddleware.php b/src/Middleware/LoggingMiddleware.php new file mode 100644 index 0000000..2c2ec66 --- /dev/null +++ b/src/Middleware/LoggingMiddleware.php @@ -0,0 +1,50 @@ +getMethod(); + $uri = (string) $request->getUri(); + + $this->logger->info(sprintf('[REQUEST] %s %s', $method, $uri), [ + 'method' => $method, + 'uri' => $uri, + 'headers' => $request->getHeaders(), + ]); + + try { + $response = $next($request); + + $this->logger->info(sprintf('[RESPONSE] %d %s', $response->getStatusCode(), $uri), [ + 'status' => $response->getStatusCode(), + 'method' => $method, + 'uri' => $uri, + ]); + + return $response; + } catch (Throwable $exception) { + $this->logger->error(sprintf('[ERROR] %s %s: %s', $method, $uri, $exception->getMessage()), [ + 'method' => $method, + 'uri' => $uri, + 'exception' => get_class($exception), + 'message' => $exception->getMessage(), + ]); + + throw $exception; + } + } +} diff --git a/src/Middleware/MiddlewareInterface.php b/src/Middleware/MiddlewareInterface.php new file mode 100644 index 0000000..dd769d7 --- /dev/null +++ b/src/Middleware/MiddlewareInterface.php @@ -0,0 +1,18 @@ + + */ + private array $middlewares = []; + + /** + * @param array $middlewares + */ + public function __construct(array $middlewares = []) + { + $this->middlewares = $middlewares; + } + + /** + * Add a middleware to the stack. + */ + public function push(MiddlewareInterface $middleware): self + { + $this->middlewares[] = $middleware; + + return $this; + } + + /** + * Process the request through the middleware stack. + * + * @param callable(RequestInterface): ResponseInterface $core + */ + public function handle(RequestInterface $request, callable $core): ResponseInterface + { + $pipeline = array_reduce( + array_reverse($this->middlewares), + fn (callable $next, MiddlewareInterface $middleware) => fn (RequestInterface $req) => $middleware->handle($req, $next), + $core + ); + + return $pipeline($request); + } +} diff --git a/src/Middleware/RetryMiddleware.php b/src/Middleware/RetryMiddleware.php new file mode 100644 index 0000000..e6661c7 --- /dev/null +++ b/src/Middleware/RetryMiddleware.php @@ -0,0 +1,65 @@ +maxAttempts + ); + + while (true) { + try { + return $next($request); + } catch (Throwable $exception) { + // Check if we should retry + if (! $this->condition->shouldRetry($exception, $context)) { + if ($context->attempt > 0) { + // We attempted retries but exhausted them + throw new RetryExhaustedException( + message: sprintf( + 'Request failed after %d attempts. Last error: %s', + $context->attempt + 1, + $exception->getMessage() + ), + context: $context, + previous: $exception + ); + } + + // First attempt failed and no retries configured/allowed + throw $exception; + } + + // Calculate delay and update context + $delay = $this->strategy->getDelay($context); + $context = $context->nextAttempt($exception, $delay); + + // Sleep before retry (convert milliseconds to microseconds) + if ($delay > 0) { + usleep($delay * 1000); + } + } + } + } +} diff --git a/src/Middleware/TimeoutMiddleware.php b/src/Middleware/TimeoutMiddleware.php new file mode 100644 index 0000000..9168daa --- /dev/null +++ b/src/Middleware/TimeoutMiddleware.php @@ -0,0 +1,32 @@ +withHeader('X-Timeout', (string) $this->timeoutSeconds); + + return $next($request); + } + + /** + * Get the timeout in seconds. + */ + public function getTimeout(): int + { + return $this->timeoutSeconds; + } +} diff --git a/src/Request.php b/src/Request.php deleted file mode 100644 index afdf2c7..0000000 --- a/src/Request.php +++ /dev/null @@ -1,22 +0,0 @@ -request = new GuzzleRequest($method, $uri, $headers, $body, $version); - } -} diff --git a/src/RequestBuilder.php b/src/RequestBuilder.php new file mode 100644 index 0000000..88cf97c --- /dev/null +++ b/src/RequestBuilder.php @@ -0,0 +1,270 @@ +> + */ + private array $headers = []; + + private StreamInterface|string|null $body = null; + + private ?Transport $transport = null; + + private SerializerInterface $serializer; + + private HttpFactory $httpFactory; + + public function __construct( + ?Transport $transport = null, + ?SerializerInterface $serializer = null, + ?HttpFactory $httpFactory = null + ) { + $this->transport = $transport; + $this->httpFactory = $httpFactory ?? HttpFactory::getInstance(); + $this->uri = $this->httpFactory->createUri(); + $this->serializer = $serializer ?? SerializerFactory::createDefault(); + } + + /** + * Set the HTTP method. + */ + public function method(string $method): self + { + $clone = clone $this; + $clone->method = strtoupper($method); + + return $clone; + } + + /** + * Set the URI. + */ + public function uri(UriInterface|string $uri): self + { + $clone = clone $this; + $clone->uri = is_string($uri) ? $this->httpFactory->createUri($uri) : $uri; + + return $clone; + } + + /** + * Add a header. + * + * @param string|array $value + */ + public function withHeader(string $name, string|array $value): self + { + $clone = clone $this; + $clone->headers[$name] = $value; + + return $clone; + } + + /** + * Add multiple headers. + * + * @param array> $headers + */ + public function withHeaders(array $headers): self + { + $clone = clone $this; + $clone->headers = array_merge($clone->headers, $headers); + + return $clone; + } + + /** + * Set the request body. + */ + public function withBody(StreamInterface|string $body): self + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + /** + * Set JSON body. + * + * Uses the injected serializer for encoding with proper error handling. + * + * @param mixed $data The data to encode as JSON + * + * @throws \Farzai\Transport\Exceptions\JsonEncodeException When encoding fails + */ + public function withJson(mixed $data): self + { + $clone = clone $this; + $clone->body = $this->serializer->encode($data); + $clone->headers['Content-Type'] = $this->serializer->getContentType(); + + return $clone; + } + + /** + * Set form data body. + * + * @param array $data + */ + public function withForm(array $data): self + { + $clone = clone $this; + $clone->body = http_build_query($data); + $clone->headers['Content-Type'] = 'application/x-www-form-urlencoded'; + + return $clone; + } + + /** + * Add query parameters. + * + * @param array $params + */ + public function withQuery(array $params): self + { + $clone = clone $this; + + // Parse existing query + $existing = []; + parse_str($clone->uri->getQuery(), $existing); + + // Merge with new params + $merged = array_merge($existing, $params); + + // Update URI with new query + $clone->uri = $clone->uri->withQuery(http_build_query($merged)); + + return $clone; + } + + /** + * Set basic authentication. + */ + public function withBasicAuth(string $username, string $password): self + { + $credentials = base64_encode($username.':'.$password); + + return $this->withHeader('Authorization', 'Basic '.$credentials); + } + + /** + * Set bearer token authentication. + */ + public function withBearerToken(string $token): self + { + return $this->withHeader('Authorization', 'Bearer '.$token); + } + + /** + * Build the PSR-7 request. + */ + public function build(): RequestInterface + { + $request = $this->httpFactory->createRequest($this->method, $this->uri); + + // Add headers + foreach ($this->headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + // Add body if present + if ($this->body !== null) { + if (is_string($this->body)) { + $stream = $this->httpFactory->createStream($this->body); + $request = $request->withBody($stream); + } else { + $request = $request->withBody($this->body); + } + } + + return $request; + } + + /** + * Send the request using the transport. + */ + public function send(): ResponseInterface + { + if ($this->transport === null) { + throw new \RuntimeException('No transport instance available. Use Transport::request() or provide transport in constructor.'); + } + + return $this->transport->sendRequest($this->build()); + } + + // Convenience methods for HTTP verbs + + /** + * Create a GET request. + */ + public static function get(string|UriInterface $uri): self + { + return (new self)->method('GET')->uri($uri); + } + + /** + * Create a POST request. + */ + public static function post(string|UriInterface $uri): self + { + return (new self)->method('POST')->uri($uri); + } + + /** + * Create a PUT request. + */ + public static function put(string|UriInterface $uri): self + { + return (new self)->method('PUT')->uri($uri); + } + + /** + * Create a PATCH request. + */ + public static function patch(string|UriInterface $uri): self + { + return (new self)->method('PATCH')->uri($uri); + } + + /** + * Create a DELETE request. + */ + public static function delete(string|UriInterface $uri): self + { + return (new self)->method('DELETE')->uri($uri); + } + + /** + * Create a HEAD request. + */ + public static function head(string|UriInterface $uri): self + { + return (new self)->method('HEAD')->uri($uri); + } + + /** + * Create an OPTIONS request. + */ + public static function options(string|UriInterface $uri): self + { + return (new self)->method('OPTIONS')->uri($uri); + } +} diff --git a/src/Response.php b/src/Response.php index 7545178..fe55d0a 100644 --- a/src/Response.php +++ b/src/Response.php @@ -1,36 +1,41 @@ serializer = $serializer ?? SerializerFactory::createDefault(); } /** @@ -64,35 +69,83 @@ public function headers(): array } /** - * Check if the response is successfull. + * Check if the response is successful. */ - public function isSuccessfull(): bool + public function isSuccessful(): bool { return $this->statusCode() >= 200 && $this->statusCode() < 300; } /** * Return the json decoded response. + * + * Uses the injected serializer (defaults to JsonSerializer with modern error handling). + * + * @param string|null $key Optional dot-notation key path (e.g., "user.name") + * @return mixed The decoded JSON data + * + * @throws \Farzai\Transport\Exceptions\JsonParseException */ public function json(?string $key = null): mixed { - if (is_null($this->jsonDecoded)) { - $this->jsonDecoded = @json_decode($this->body(), true) ?: false; + // Use caching to avoid re-parsing on subsequent calls + if (! $this->jsonParsed) { + $body = $this->body(); + + // Delegate to the injected serializer + $this->jsonDecoded = $this->serializer->decode($body); + $this->jsonParsed = true; } - if ($this->jsonDecoded === false) { + // If no key specified, return the full decoded data + if ($key === null) { + return $this->jsonDecoded; + } + + // If decoder already handled key extraction, don't do it again + // This branch is for backward compatibility when jsonDecoded is already an array + if (is_array($this->jsonDecoded)) { + return \Farzai\Support\Arr::get($this->jsonDecoded, $key); + } + + return null; + } + + /** + * Get the JSON decoded response, returning null instead of throwing on parse error. + * + * @param string|null $key Optional dot-notation key path + * @return mixed The decoded data or null on failure + */ + public function jsonOrNull(?string $key = null): mixed + { + try { + return $this->json($key); + } catch (\Farzai\Transport\Exceptions\JsonParseException) { return null; } + } - if (is_null($key)) { - return $this->jsonDecoded; + /** + * Convert response to array. + * + * @return array + * + * @throws \Farzai\Transport\Exceptions\JsonParseException + */ + public function toArray(): array + { + $data = $this->json(); + + if (! is_array($data)) { + return []; } - return Arr::get($this->jsonDecoded, $key); + return $data; } /** - * Throw an exception if the response is not successfull. + * Throw an exception if the response is not successful. * * @param callable|null $callback Custom callback to throw an exception. * @return $this @@ -102,7 +155,7 @@ public function json(?string $key = null): mixed public function throw(?callable $callback = null) { $callback = $callback ?? function (ResponseInterface $response, ?\Exception $e) { - if (! $this->isSuccessfull()) { + if (! $this->isSuccessful()) { throw $e; } @@ -111,7 +164,7 @@ public function throw(?callable $callback = null) return $callback( $this, - ! $this->isSuccessfull() ? ResponseExceptionFactory::create($this) : null + ! $this->isSuccessful() ? ResponseExceptionFactory::create($this) : null ) ?: $this; } @@ -122,4 +175,98 @@ public function getPsrRequest(): PsrRequestInterface { return $this->request; } + + // PSR-7 ResponseInterface implementation + + public function getStatusCode(): int + { + return $this->response->getStatusCode(); + } + + public function getReasonPhrase(): string + { + return $this->response->getReasonPhrase(); + } + + public function getProtocolVersion(): string + { + return $this->response->getProtocolVersion(); + } + + /** + * @return array> + */ + public function getHeaders(): array + { + return $this->response->getHeaders(); + } + + public function hasHeader(string $name): bool + { + return $this->response->hasHeader($name); + } + + /** + * @return array + */ + public function getHeader(string $name): array + { + return $this->response->getHeader($name); + } + + public function getHeaderLine(string $name): string + { + return $this->response->getHeaderLine($name); + } + + public function getBody(): StreamInterface + { + return $this->response->getBody(); + } + + public function withProtocolVersion(string $version): static + { + return new static($this->request, $this->response->withProtocolVersion($version), $this->serializer); + } + + /** + * @param string|array $value + */ + public function withHeader(string $name, $value): static + { + return new static($this->request, $this->response->withHeader($name, $value), $this->serializer); + } + + /** + * @param string|array $value + */ + public function withAddedHeader(string $name, $value): static + { + return new static($this->request, $this->response->withAddedHeader($name, $value), $this->serializer); + } + + public function withoutHeader(string $name): static + { + return new static($this->request, $this->response->withoutHeader($name), $this->serializer); + } + + public function withBody(StreamInterface $body): static + { + return new static($this->request, $this->response->withBody($body), $this->serializer); + } + + public function withStatus(int $code, string $reasonPhrase = ''): static + { + return new static($this->request, $this->response->withStatus($code, $reasonPhrase), $this->serializer); + } + + /** + * Get the serializer instance. + * + * @return SerializerInterface The current serializer + */ + public function getSerializer(): SerializerInterface + { + return $this->serializer; + } } diff --git a/src/ResponseBuilder.php b/src/ResponseBuilder.php index 075d01f..8815e97 100644 --- a/src/ResponseBuilder.php +++ b/src/ResponseBuilder.php @@ -1,7 +1,10 @@ httpFactory = $httpFactory ?? HttpFactory::getInstance(); + } + /** * Create a new response builder instance. */ - public static function create(): ResponseBuilder + public static function create(?HttpFactory $httpFactory = null): ResponseBuilder { - return new static(); + return new static($httpFactory); } /** @@ -110,12 +125,30 @@ public function withReason(string $reason): self */ public function build(): PsrResponseInterface { - return ResponseFactory::create( + $response = $this->httpFactory->createResponse( $this->statusCode, - $this->headers, - $this->body, - $this->version, - $this->reason + $this->reason ?? '' ); + + // Add headers + foreach ($this->headers as $name => $values) { + $response = $response->withHeader($name, $values); + } + + // Add body if present + if ($this->body !== null) { + $stream = is_string($this->body) + ? $this->httpFactory->createStream($this->body) + : $this->body; + + $response = $response->withBody($stream); + } + + // Set protocol version if different from default + if ($this->version !== '1.1') { + $response = $response->withProtocolVersion($this->version); + } + + return $response; } } diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php deleted file mode 100644 index 31ccd28..0000000 --- a/src/ResponseFactory.php +++ /dev/null @@ -1,22 +0,0 @@ -baseDelayMs * ($this->multiplier ** $context->attempt)); + + // Cap at maximum delay + $delay = min($delay, $this->maxDelayMs); + + // Add jitter if enabled (randomize between 0% and 100% of calculated delay) + if ($this->useJitter) { + $delay = (int) ($delay * (mt_rand(0, 100) / 100)); + } + + return $delay; + } +} diff --git a/src/Retry/FixedDelayStrategy.php b/src/Retry/FixedDelayStrategy.php new file mode 100644 index 0000000..4773cf0 --- /dev/null +++ b/src/Retry/FixedDelayStrategy.php @@ -0,0 +1,20 @@ +delayMs; + } +} diff --git a/src/Retry/RetryCondition.php b/src/Retry/RetryCondition.php new file mode 100644 index 0000000..a5bcc35 --- /dev/null +++ b/src/Retry/RetryCondition.php @@ -0,0 +1,92 @@ + + */ + private array $conditions = []; + + /** + * Create a default retry condition that retries on any exception. + */ + public static function default(): self + { + $condition = new self; + $condition->onAnyException(); + + return $condition; + } + + /** + * Retry on any exception. + */ + public function onAnyException(): self + { + $this->conditions[] = fn (Throwable $exception, RetryContext $context) => true; + + return $this; + } + + /** + * Retry only on specific exception types. + * + * @param array> $exceptionClasses + */ + public function onExceptions(array $exceptionClasses): self + { + $this->conditions[] = function (Throwable $exception) use ($exceptionClasses) { + foreach ($exceptionClasses as $class) { + if ($exception instanceof $class) { + return true; + } + } + + return false; + }; + + return $this; + } + + /** + * Add a custom retry condition. + * + * @param callable(Throwable, RetryContext): bool $callback + */ + public function when(callable $callback): self + { + $this->conditions[] = $callback; + + return $this; + } + + /** + * Check if we should retry based on the exception and context. + */ + public function shouldRetry(Throwable $exception, RetryContext $context): bool + { + if (! $context->hasRetriesLeft()) { + return false; + } + + // If no conditions, don't retry + if (empty($this->conditions)) { + return false; + } + + // Check if any condition matches + foreach ($this->conditions as $condition) { + if ($condition($exception, $context)) { + return true; + } + } + + return false; + } +} diff --git a/src/Retry/RetryContext.php b/src/Retry/RetryContext.php new file mode 100644 index 0000000..6b8c3ec --- /dev/null +++ b/src/Retry/RetryContext.php @@ -0,0 +1,55 @@ + $delaysUsed Delays in milliseconds that were used for each retry + * @param array $exceptions Exceptions encountered during retries + */ + public function __construct( + public readonly RequestInterface $request, + public readonly int $attempt, + public readonly int $maxAttempts, + public readonly ?Throwable $lastException = null, + public readonly array $delaysUsed = [], + public readonly array $exceptions = [] + ) {} + + /** + * Check if we have retries remaining. + */ + public function hasRetriesLeft(): bool + { + return $this->attempt < $this->maxAttempts; + } + + /** + * Get the number of retries remaining. + */ + public function retriesLeft(): int + { + return max(0, $this->maxAttempts - $this->attempt); + } + + /** + * Create a new context for the next retry attempt. + */ + public function nextAttempt(Throwable $exception, int $delayUsed): self + { + return new self( + request: $this->request, + attempt: $this->attempt + 1, + maxAttempts: $this->maxAttempts, + lastException: $exception, + delaysUsed: [...$this->delaysUsed, $delayUsed], + exceptions: [...$this->exceptions, $exception] + ); + } +} diff --git a/src/Retry/RetryStrategyInterface.php b/src/Retry/RetryStrategyInterface.php new file mode 100644 index 0000000..58332bc --- /dev/null +++ b/src/Retry/RetryStrategyInterface.php @@ -0,0 +1,15 @@ +validate(); + } + + /** + * Create a default configuration. + */ + public static function default(): self + { + return new self; + } + + /** + * Create a strict configuration for validation. + * + * Uses stricter settings suitable for validating untrusted input. + */ + public static function strict(): self + { + return new self( + maxDepth: 128, // Lower depth limit + encodeFlags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION, + decodeFlags: JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING, + associative: true + ); + } + + /** + * Create a lenient configuration. + * + * More permissive settings for backwards compatibility. + */ + public static function lenient(): self + { + return new self( + maxDepth: 1024, + encodeFlags: JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE, + decodeFlags: JSON_BIGINT_AS_STRING | JSON_INVALID_UTF8_SUBSTITUTE, + associative: true + ); + } + + /** + * Create a pretty-print configuration for debugging. + */ + public static function prettyPrint(): self + { + return new self( + encodeFlags: JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + } + + /** + * Create a new configuration with a different max depth. + */ + public function withMaxDepth(int $maxDepth): self + { + return new self( + maxDepth: $maxDepth, + encodeFlags: $this->encodeFlags, + decodeFlags: $this->decodeFlags, + associative: $this->associative + ); + } + + /** + * Create a new configuration with different encode flags. + */ + public function withEncodeFlags(int $flags): self + { + return new self( + maxDepth: $this->maxDepth, + encodeFlags: $flags, + decodeFlags: $this->decodeFlags, + associative: $this->associative + ); + } + + /** + * Create a new configuration with different decode flags. + */ + public function withDecodeFlags(int $flags): self + { + return new self( + maxDepth: $this->maxDepth, + encodeFlags: $this->encodeFlags, + decodeFlags: $flags, + associative: $this->associative + ); + } + + /** + * Create a new configuration with different associative setting. + */ + public function withAssociative(bool $associative): self + { + return new self( + maxDepth: $this->maxDepth, + encodeFlags: $this->encodeFlags, + decodeFlags: $this->decodeFlags, + associative: $associative + ); + } + + /** + * Validate the configuration. + * + * @throws \InvalidArgumentException When configuration is invalid + */ + private function validate(): void + { + if ($this->maxDepth < 1) { + throw new \InvalidArgumentException('Max depth must be at least 1.'); + } + + if ($this->maxDepth > 2147483647) { + throw new \InvalidArgumentException('Max depth exceeds maximum allowed value.'); + } + } +} diff --git a/src/Serialization/JsonSerializer.php b/src/Serialization/JsonSerializer.php new file mode 100644 index 0000000..8c50372 --- /dev/null +++ b/src/Serialization/JsonSerializer.php @@ -0,0 +1,162 @@ +config->encodeFlags, + depth: $this->config->maxDepth + ); + } catch (\JsonException $e) { + throw JsonEncodeException::fromJsonException($e, $data, $this->config->maxDepth); + } + } + + /** + * Decode JSON string to structured data. + * + * Uses modern PHP error handling and protects against integer overflow + * by using JSON_BIGINT_AS_STRING. + * + * @param string $data The JSON string to decode + * @param string|null $key Optional dot-notation key path (e.g., "user.name") + * @return mixed The decoded data + * + * @throws JsonParseException When decoding fails + */ + public function decode(string $data, ?string $key = null): mixed + { + // Handle empty string + if ($data === '') { + return null; + } + + try { + $decoded = json_decode( + json: $data, + associative: $this->config->associative, + depth: $this->config->maxDepth, + flags: $this->config->decodeFlags + ); + + // If no key path specified, return the full decoded data + if ($key === null) { + return $decoded; + } + + // Extract nested value using dot notation + return $this->extractValue($decoded, $key); + } catch (\JsonException $e) { + throw JsonParseException::fromJsonException($e, $data, $this->config->maxDepth); + } + } + + /** + * Safely decode JSON, returning null on failure. + * + * This method catches all JSON parsing exceptions and returns null + * instead, making it useful for optional or best-effort parsing. + * + * @param string $data The JSON string to decode + * @param string|null $key Optional dot-notation key path + * @return mixed The decoded data or null on failure + */ + public function decodeOrNull(string $data, ?string $key = null): mixed + { + try { + return $this->decode($data, $key); + } catch (JsonParseException) { + return null; + } + } + + /** + * Get the content type for JSON. + * + * @return string The MIME type + */ + public function getContentType(): string + { + return 'application/json'; + } + + /** + * Get the configuration. + * + * @return JsonConfig The current configuration + */ + public function getConfig(): JsonConfig + { + return $this->config; + } + + /** + * Extract a value from decoded data using dot notation. + * + * @param mixed $data The decoded data + * @param string $key The dot-notation key path + * @return mixed The extracted value + */ + private function extractValue(mixed $data, string $key): mixed + { + // If data is an array, use the Arr helper from farzai/support + if (is_array($data)) { + return Arr::get($data, $key); + } + + // If data is an object, convert to array for extraction + if (is_object($data)) { + return Arr::get(json_decode(json_encode($data), true), $key); + } + + // For scalar values, only return if key is empty + return $key === '' ? $data : null; + } +} diff --git a/src/Serialization/SerializerFactory.php b/src/Serialization/SerializerFactory.php new file mode 100644 index 0000000..4a3f44e --- /dev/null +++ b/src/Serialization/SerializerFactory.php @@ -0,0 +1,171 @@ +> + */ + private static array $contentTypeMap = [ + 'application/json' => JsonSerializer::class, + 'text/json' => JsonSerializer::class, + 'application/javascript' => JsonSerializer::class, + 'application/x-javascript' => JsonSerializer::class, + ]; + + /** + * Registry of custom serializers. + * + * @var array + */ + private static array $customSerializers = []; + + /** + * Create a default JSON serializer with standard configuration. + * + * @return SerializerInterface The default serializer + */ + public static function createDefault(): SerializerInterface + { + return new JsonSerializer(JsonConfig::default()); + } + + /** + * Create a JSON serializer with strict configuration. + * + * Useful for validating untrusted input. + * + * @return SerializerInterface The strict serializer + */ + public static function createStrict(): SerializerInterface + { + return new JsonSerializer(JsonConfig::strict()); + } + + /** + * Create a JSON serializer with lenient configuration. + * + * More permissive settings for backwards compatibility. + * + * @return SerializerInterface The lenient serializer + */ + public static function createLenient(): SerializerInterface + { + return new JsonSerializer(JsonConfig::lenient()); + } + + /** + * Create a JSON serializer with pretty-print configuration. + * + * Useful for debugging and human-readable output. + * + * @return SerializerInterface The pretty-print serializer + */ + public static function createPrettyPrint(): SerializerInterface + { + return new JsonSerializer(JsonConfig::prettyPrint()); + } + + /** + * Create a serializer from a content type. + * + * This method examines the content type header and returns an + * appropriate serializer. Currently only JSON is supported. + * + * @param string $contentType The content type (e.g., "application/json") + * @return SerializerInterface The appropriate serializer + * + * @throws \InvalidArgumentException When content type is not supported + */ + public static function createFromContentType(string $contentType): SerializerInterface + { + // Normalize content type (remove charset, trim, lowercase) + $normalizedType = strtolower(trim(explode(';', $contentType)[0])); + + // Check if a custom serializer is registered for this type + if (isset(self::$customSerializers[$normalizedType])) { + return self::$customSerializers[$normalizedType]; + } + + // Check if a built-in serializer exists for this type + if (isset(self::$contentTypeMap[$normalizedType])) { + $serializerClass = self::$contentTypeMap[$normalizedType]; + + return new $serializerClass; + } + + throw new \InvalidArgumentException( + sprintf('Unsupported content type: %s', $contentType) + ); + } + + /** + * Create a JSON serializer with custom configuration. + * + * @param JsonConfig $config The custom configuration + * @return SerializerInterface The configured serializer + */ + public static function createWithConfig(JsonConfig $config): SerializerInterface + { + return new JsonSerializer($config); + } + + /** + * Register a custom serializer for a content type. + * + * This allows extending the factory with custom serialization formats + * without modifying the factory class itself (Open/Closed Principle). + * + * @param string $contentType The content type to register + * @param SerializerInterface $serializer The serializer instance + */ + public static function registerCustomSerializer(string $contentType, SerializerInterface $serializer): void + { + $normalizedType = strtolower(trim(explode(';', $contentType)[0])); + self::$customSerializers[$normalizedType] = $serializer; + } + + /** + * Check if a content type is supported. + * + * @param string $contentType The content type to check + * @return bool True if supported, false otherwise + */ + public static function supports(string $contentType): bool + { + $normalizedType = strtolower(trim(explode(';', $contentType)[0])); + + return isset(self::$customSerializers[$normalizedType]) + || isset(self::$contentTypeMap[$normalizedType]); + } + + /** + * Clear all custom serializers. + * + * Useful for testing. + */ + public static function clearCustomSerializers(): void + { + self::$customSerializers = []; + } +} diff --git a/src/Traits/PsrRequestTrait.php b/src/Traits/PsrRequestTrait.php deleted file mode 100644 index 4840044..0000000 --- a/src/Traits/PsrRequestTrait.php +++ /dev/null @@ -1,113 +0,0 @@ -request->getRequestTarget(); - } - - public function withRequestTarget($requestTarget): PsrRequestInterface - { - $this->request = $this->request->withRequestTarget($requestTarget); - - return $this; - } - - public function getMethod(): string - { - return $this->request->getMethod(); - } - - public function withMethod($method): PsrRequestInterface - { - $this->request = $this->request->withMethod($method); - - return $this; - } - - public function getUri(): UriInterface - { - return $this->request->getUri(); - } - - public function withUri($uri, $preserveHost = false): PsrRequestInterface - { - $this->request = $this->request->withUri($uri, $preserveHost); - - return $this; - } - - public function getProtocolVersion(): string - { - return $this->request->getProtocolVersion(); - } - - public function withProtocolVersion($version): PsrRequestInterface - { - $this->request = $this->request->withProtocolVersion($version); - - return $this; - } - - public function getHeaders(): array - { - return $this->request->getHeaders(); - } - - public function hasHeader($name): bool - { - return $this->request->hasHeader($name); - } - - public function getHeader($name): array - { - return $this->request->getHeader($name); - } - - public function getHeaderLine($name): string - { - return $this->request->getHeaderLine($name); - } - - public function withHeader($name, $value): PsrRequestInterface - { - $this->request = $this->request->withHeader($name, $value); - - return $this; - } - - public function withAddedHeader($name, $value): PsrRequestInterface - { - $this->request = $this->request->withAddedHeader($name, $value); - - return $this; - } - - public function withoutHeader($name): PsrRequestInterface - { - $this->request = $this->request->withoutHeader($name); - - return $this; - } - - public function getBody(): StreamInterface - { - return $this->request->getBody(); - } - - public function withBody(StreamInterface $body): PsrRequestInterface - { - $this->request = $this->request->withBody($body); - - return $this; - } -} diff --git a/src/Traits/PsrResponseTrait.php b/src/Traits/PsrResponseTrait.php deleted file mode 100644 index 300edc5..0000000 --- a/src/Traits/PsrResponseTrait.php +++ /dev/null @@ -1,76 +0,0 @@ -response->getStatusCode(); - } - - public function getReasonPhrase(): string - { - return $this->response->getReasonPhrase(); - } - - public function getProtocolVersion(): string - { - return $this->response->getProtocolVersion(); - } - - public function getHeaders(): array - { - return $this->response->getHeaders(); - } - - public function hasHeader($name): bool - { - return $this->response->hasHeader($name); - } - - public function getHeader($name): array - { - return $this->response->getHeader($name); - } - - public function getHeaderLine($name): string - { - return $this->response->getHeaderLine($name); - } - - public function getBody(): \Psr\Http\Message\StreamInterface - { - return $this->response->getBody(); - } - - public function withProtocolVersion($version): static - { - return new static($this->request, $this->response->withProtocolVersion($version)); - } - - public function withHeader($name, $value): static - { - return new static($this->request, $this->response->withHeader($name, $value)); - } - - public function withAddedHeader($name, $value): static - { - return new static($this->request, $this->response->withAddedHeader($name, $value)); - } - - public function withoutHeader($name): static - { - return new static($this->request, $this->response->withoutHeader($name)); - } - - public function withBody(\Psr\Http\Message\StreamInterface $body): static - { - return new static($this->request, $this->response->withBody($body)); - } - - public function withStatus($code, $reasonPhrase = ''): static - { - return new static($this->request, $this->response->withStatus($code, $reasonPhrase)); - } -} diff --git a/src/Transport.php b/src/Transport.php index 8db551f..899b1ee 100644 --- a/src/Transport.php +++ b/src/Transport.php @@ -4,8 +4,9 @@ namespace Farzai\Transport; -use Farzai\Transport\Exceptions\MaxRetriesExceededException; -use GuzzleHttp\Psr7\Uri; +use Farzai\Transport\Contracts\ResponseInterface; +use Farzai\Transport\Factory\HttpFactory; +use Farzai\Transport\Middleware\MiddlewareStack; use Psr\Http\Client\ClientInterface as PsrClientInterface; use Psr\Http\Message\RequestInterface as PsrRequestInterface; use Psr\Http\Message\ResponseInterface as PsrResponseInterface; @@ -13,200 +14,190 @@ class Transport implements PsrClientInterface { - /** - * The client instance. - */ - private PsrClientInterface $client; + private readonly MiddlewareStack $middlewareStack; + + private readonly HttpFactory $httpFactory; /** - * The logger instance. + * Create a new transport instance. */ - private PsrLoggerInterface $logger; + public function __construct( + private readonly TransportConfig $config, + ?HttpFactory $httpFactory = null + ) { + $this->middlewareStack = new MiddlewareStack($this->config->middlewares); + $this->httpFactory = $httpFactory ?? HttpFactory::getInstance(); + } /** - * The base URI. + * Send a PSR request and get a PSR response (for PSR-18 compatibility). */ - private string $uri = '/'; + public function sendRequest(PsrRequestInterface $request): PsrResponseInterface + { + $request = $this->prepareRequest($request); + + $response = $this->middlewareStack->handle( + request: $request, + core: fn (PsrRequestInterface $req) => $this->config->client->sendRequest($req) + ); + + return $this->wrapResponse($request, $response); + } /** - * The headers. - * - * @var array + * Send a request and get our custom Response with helpers. */ - private array $headers = []; + public function send(PsrRequestInterface $request): ResponseInterface + { + $psrResponse = $this->sendRequest($request); - private int $timeout = 30; + if ($psrResponse instanceof ResponseInterface) { + return $psrResponse; + } - private int $retries = 0; + return new Response($request, $psrResponse); + } /** - * Create a new client instance. + * Create a fluent request builder. */ - public function __construct(PsrClientInterface $client, PsrLoggerInterface $logger) + public function request(): RequestBuilder { - $this->client = $client; - $this->logger = $logger; + return new RequestBuilder($this); } /** - * Set the base URI. + * Convenience method for GET request. */ - public function setUri(string $uri): self + public function get(string $uri): RequestBuilder { - $this->uri = $uri; - - return $this; + return $this->request()->method('GET')->uri($uri); } /** - * Get the URI. + * Convenience method for POST request. */ - public function getUri(): string + public function post(string $uri): RequestBuilder { - return $this->uri; + return $this->request()->method('POST')->uri($uri); } /** - * Set the timeout. + * Convenience method for PUT request. */ - public function setTimeout(int $timeout): self + public function put(string $uri): RequestBuilder { - if ($timeout < 0) { - throw new \InvalidArgumentException('Timeout must be greater than or equal to 0.'); - } - - $this->timeout = $timeout; - - return $this; + return $this->request()->method('PUT')->uri($uri); } /** - * Get the timeout. + * Convenience method for PATCH request. */ - public function getTimeout(): int + public function patch(string $uri): RequestBuilder { - return $this->timeout; + return $this->request()->method('PATCH')->uri($uri); } /** - * Set the retries. + * Convenience method for DELETE request. */ - public function setRetries(int $retries): self + public function delete(string $uri): RequestBuilder { - if ($retries < 0) { - throw new \InvalidArgumentException('Retries must be greater than or equal to 0.'); - } - - $this->retries = $retries; + return $this->request()->method('DELETE')->uri($uri); + } - return $this; + /** + * Get the underlying PSR-18 client. + */ + public function getPsrClient(): PsrClientInterface + { + return $this->config->client; } /** - * Get the retries. + * Get the logger. */ - public function getRetries(): int + public function getLogger(): PsrLoggerInterface { - return $this->retries; + return $this->config->logger; } /** - * Set the headers. - * - * @param array $headers + * Get the configuration. */ - public function setHeaders(array $headers): self + public function getConfig(): TransportConfig { - $this->headers = array_merge($this->headers, $headers); + return $this->config; + } - return $this; + /** + * Get the base URI. + */ + public function getUri(): string + { + return $this->config->baseUri; } /** - * Get the headers. + * Get the default headers. * * @return array */ public function getHeaders(): array { - return $this->headers; - } - - public function getLogger(): PsrLoggerInterface - { - return $this->logger; + return $this->config->headers; } - public function sendRequest(PsrRequestInterface $request): PsrResponseInterface + /** + * Get the timeout. + */ + public function getTimeout(): int { - $request = $this->setupRequest($request); - - $this->logger->info('[REQUEST] '.$request->getMethod().' '.$request->getUri()); - - while ($this->retries >= 0) { - try { - return $this->client->sendRequest($request); - } catch (\Throwable $e) { - $this->logger->error($e->getMessage()); - } - - $this->retries--; - - if ($this->retries >= 0) { - $this->logger->info('[RETRY] '.$this->retries.' retries left.'); - } - } - - $this->logger->error('[MAX RETRIES EXCEEDED]'); - - throw ($e ?? new MaxRetriesExceededException('Max retries exceeded.')); + return $this->config->timeout; } /** - * Get the client instance. + * Get the max retries. */ - public function getPsrClient(): PsrClientInterface + public function getRetries(): int { - return $this->client; + return $this->config->maxRetries; } /** - * Setup the request. + * Prepare the request by adding base URI and default headers. */ - public function setupRequest(PsrRequestInterface $request): PsrRequestInterface + private function prepareRequest(PsrRequestInterface $request): PsrRequestInterface { $uri = $request->getUri(); - if (empty($uri->getHost())) { - $request = $this->setupConnectionUri($request); + // If no host, prepend base URI + if (empty($uri->getHost()) && ! empty($this->config->baseUri)) { + $baseUri = $this->httpFactory->createUri($this->config->baseUri); + $uri = $baseUri->withPath($baseUri->getPath().$uri->getPath()); + $uri = $uri->withQuery($uri->getQuery()); + $request = $request->withUri($uri); } - $request = $this->decorateRequest($request); + // Add default headers + foreach ($this->config->headers as $name => $value) { + if (! $request->hasHeader($name)) { + $request = $request->withHeader($name, $value); + } + } return $request; } /** - * Set the connection URI. - */ - public function setupConnectionUri(PsrRequestInterface $request): PsrRequestInterface - { - $uri = new Uri($this->uri); - $uri = $uri->withPath($uri->getPath().$request->getUri()->getPath()); - $uri = $uri->withQuery($request->getUri()->getQuery()); - - return $request->withUri($uri); - } - - /** - * Decorate the request. + * Wrap PSR response in our custom Response. */ - public function decorateRequest(PsrRequestInterface $request): PsrRequestInterface + private function wrapResponse(PsrRequestInterface $request, PsrResponseInterface $response): ResponseInterface { - foreach ($this->headers as $name => $value) { - $request = $request->withHeader($name, $value); + if ($response instanceof ResponseInterface) { + return $response; } - return $request; + return new Response($request, $response); } } diff --git a/src/TransportBuilder.php b/src/TransportBuilder.php index 4e40cbf..7287795 100644 --- a/src/TransportBuilder.php +++ b/src/TransportBuilder.php @@ -4,72 +4,223 @@ namespace Farzai\Transport; -use GuzzleHttp\Client as GuzzleClient; +use Farzai\Transport\Factory\ClientFactory; +use Farzai\Transport\Middleware\LoggingMiddleware; +use Farzai\Transport\Middleware\MiddlewareInterface; +use Farzai\Transport\Middleware\RetryMiddleware; +use Farzai\Transport\Middleware\TimeoutMiddleware; +use Farzai\Transport\Retry\ExponentialBackoffStrategy; +use Farzai\Transport\Retry\RetryCondition; +use Farzai\Transport\Retry\RetryStrategyInterface; use Psr\Http\Client\ClientInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; final class TransportBuilder { + private ?ClientInterface $client = null; + + private ?LoggerInterface $logger = null; + + private string $baseUri = ''; + /** - * The client instance. + * @var array */ - private ?ClientInterface $client; + private array $headers = []; + + private int $timeout = 30; + + private int $maxRetries = 0; + + private ?RetryStrategyInterface $retryStrategy = null; + + private ?RetryCondition $retryCondition = null; /** - * The logger instance. + * @var array */ - private ?LoggerInterface $logger; + private array $middlewares = []; + + private bool $useDefaultMiddlewares = true; /** * Create a new builder instance. */ public static function make(): static { - return new self(); + return new self; } /** - * Set the client + * Set the HTTP client. */ - public function setClient(ClientInterface $client) + public function setClient(ClientInterface $client): self { - $this->client = $client; + $clone = clone $this; + $clone->client = $client; - return $this; + return $clone; } - public function getClient(): ?ClientInterface + /** + * Set the logger. + */ + public function setLogger(LoggerInterface $logger): self { - return $this->client; + $clone = clone $this; + $clone->logger = $logger; + + return $clone; + } + + /** + * Set the base URI. + */ + public function withBaseUri(string $uri): self + { + $clone = clone $this; + $clone->baseUri = $uri; + + return $clone; + } + + /** + * Set default headers. + * + * @param array $headers + */ + public function withHeaders(array $headers): self + { + $clone = clone $this; + $clone->headers = array_merge($clone->headers, $headers); + + return $clone; + } + + /** + * Set the timeout in seconds. + */ + public function withTimeout(int $seconds): self + { + $clone = clone $this; + $clone->timeout = $seconds; + + return $clone; } /** - * Set the logger + * Configure retry behavior. */ - public function setLogger(LoggerInterface $logger) + public function withRetries( + int $maxRetries, + ?RetryStrategyInterface $strategy = null, + ?RetryCondition $condition = null + ): self { + $clone = clone $this; + $clone->maxRetries = $maxRetries; + $clone->retryStrategy = $strategy; + $clone->retryCondition = $condition; + + return $clone; + } + + /** + * Add a custom middleware. + */ + public function withMiddleware(MiddlewareInterface $middleware): self + { + $clone = clone $this; + $clone->middlewares[] = $middleware; + + return $clone; + } + + /** + * Disable default middlewares (logging, timeout, retry). + */ + public function withoutDefaultMiddlewares(): self { - $this->logger = $logger; + $clone = clone $this; + $clone->useDefaultMiddlewares = false; - return $this; + return $clone; } + /** + * Get the configured client. + */ + public function getClient(): ?ClientInterface + { + return $this->client; + } + + /** + * Get the configured logger. + */ public function getLogger(): ?LoggerInterface { return $this->logger; } /** - * Build the transport. + * Build the transport with configured settings. + * + * Auto-detects PSR-18 HTTP client if none is provided. + * Discovery order: Symfony HTTP Client → Guzzle → Other PSR-18 clients */ public function build(): Transport { - $logger = $this->logger ?? new NullLogger(); - $client = $this->client ?? new GuzzleClient(); + $logger = $this->logger ?? new NullLogger; - return new Transport( + // Auto-detect client if not explicitly set + // This allows users to use any PSR-18 client without configuration + $client = $this->client ?? ClientFactory::create($logger); + + $config = new TransportConfig( client: $client, logger: $logger, + baseUri: $this->baseUri, + headers: $this->headers, + timeout: $this->timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy ?? new ExponentialBackoffStrategy, + retryCondition: $this->retryCondition ?? RetryCondition::default(), + middlewares: $this->buildMiddlewares($logger) ); + + return new Transport($config); + } + + /** + * Build the middleware stack. + * + * @return array + */ + private function buildMiddlewares(LoggerInterface $logger): array + { + $middlewares = []; + + if ($this->useDefaultMiddlewares) { + // Add logging middleware + $middlewares[] = new LoggingMiddleware($logger); + + // Add timeout middleware if configured + if ($this->timeout > 0) { + $middlewares[] = new TimeoutMiddleware($this->timeout); + } + + // Add retry middleware if configured + if ($this->maxRetries > 0) { + $middlewares[] = new RetryMiddleware( + maxAttempts: $this->maxRetries, + strategy: $this->retryStrategy ?? new ExponentialBackoffStrategy, + condition: $this->retryCondition ?? RetryCondition::default() + ); + } + } + + // Add custom middlewares + return array_merge($middlewares, $this->middlewares); } } diff --git a/src/TransportConfig.php b/src/TransportConfig.php new file mode 100644 index 0000000..88f1d58 --- /dev/null +++ b/src/TransportConfig.php @@ -0,0 +1,169 @@ + $headers + * @param array $middlewares + */ + public function __construct( + public readonly ClientInterface $client, + public readonly LoggerInterface $logger = new NullLogger, + public readonly string $baseUri = '', + public readonly array $headers = [], + public readonly int $timeout = 30, + public readonly int $maxRetries = 0, + public readonly RetryStrategyInterface $retryStrategy = new ExponentialBackoffStrategy, + public readonly RetryCondition $retryCondition = new RetryCondition, + public readonly array $middlewares = [] + ) { + $this->validate(); + } + + /** + * Validate the configuration. + */ + private function validate(): void + { + if ($this->timeout < 0) { + throw new \InvalidArgumentException('Timeout must be greater than or equal to 0.'); + } + + if ($this->maxRetries < 0) { + throw new \InvalidArgumentException('Max retries must be greater than or equal to 0.'); + } + + foreach ($this->middlewares as $middleware) { + if (! $middleware instanceof MiddlewareInterface) { + throw new \InvalidArgumentException( + sprintf('Middleware must implement %s', MiddlewareInterface::class) + ); + } + } + } + + /** + * Create a new configuration with modified values. + * + * @param array $headers + */ + public function withHeaders(array $headers): self + { + return new self( + client: $this->client, + logger: $this->logger, + baseUri: $this->baseUri, + headers: array_merge($this->headers, $headers), + timeout: $this->timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy, + retryCondition: $this->retryCondition, + middlewares: $this->middlewares + ); + } + + /** + * Create a new configuration with a modified base URI. + */ + public function withBaseUri(string $baseUri): self + { + return new self( + client: $this->client, + logger: $this->logger, + baseUri: $baseUri, + headers: $this->headers, + timeout: $this->timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy, + retryCondition: $this->retryCondition, + middlewares: $this->middlewares + ); + } + + /** + * Create a new configuration with a modified timeout. + */ + public function withTimeout(int $timeout): self + { + return new self( + client: $this->client, + logger: $this->logger, + baseUri: $this->baseUri, + headers: $this->headers, + timeout: $timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy, + retryCondition: $this->retryCondition, + middlewares: $this->middlewares + ); + } + + /** + * Create a new configuration with modified retry settings. + */ + public function withRetries( + int $maxRetries, + ?RetryStrategyInterface $strategy = null, + ?RetryCondition $condition = null + ): self { + return new self( + client: $this->client, + logger: $this->logger, + baseUri: $this->baseUri, + headers: $this->headers, + timeout: $this->timeout, + maxRetries: $maxRetries, + retryStrategy: $strategy ?? $this->retryStrategy, + retryCondition: $condition ?? $this->retryCondition, + middlewares: $this->middlewares + ); + } + + /** + * Create a new configuration with additional middleware. + */ + public function withMiddleware(MiddlewareInterface $middleware): self + { + return new self( + client: $this->client, + logger: $this->logger, + baseUri: $this->baseUri, + headers: $this->headers, + timeout: $this->timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy, + retryCondition: $this->retryCondition, + middlewares: [...$this->middlewares, $middleware] + ); + } + + /** + * Create a new configuration with modified logger. + */ + public function withLogger(LoggerInterface $logger): self + { + return new self( + client: $this->client, + logger: $logger, + baseUri: $this->baseUri, + headers: $this->headers, + timeout: $this->timeout, + maxRetries: $this->maxRetries, + retryStrategy: $this->retryStrategy, + retryCondition: $this->retryCondition, + middlewares: $this->middlewares + ); + } +} diff --git a/tests/ExceptionTest.php b/tests/ExceptionTest.php new file mode 100644 index 0000000..714f118 --- /dev/null +++ b/tests/ExceptionTest.php @@ -0,0 +1,312 @@ +shouldReceive('getMethod')->andReturn('GET'); + $request->shouldReceive('getUri')->andReturn(new \Nyholm\Psr7\Uri('https://example.com/api')); + $request->shouldReceive('getHeaders')->andReturn(['Accept' => ['application/json']]); + + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('getStatusCode')->andReturn(500); + $response->shouldReceive('getReasonPhrase')->andReturn('Internal Server Error'); + $response->shouldReceive('getHeaders')->andReturn(['Content-Type' => ['application/json']]); + + $exception = new HttpException('Server error', $request, $response); + + expect($exception->getMessage())->toBe('Server error') + ->and($exception->getRequest())->toBe($request) + ->and($exception->getResponse())->toBe($response) + ->and($exception->hasResponse())->toBeTrue() + ->and($exception->getStatusCode())->toBe(500); + }); + + it('can be created without response', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new HttpException('Network error', $request); + + expect($exception->getRequest())->toBe($request) + ->and($exception->getResponse())->toBeNull() + ->and($exception->hasResponse())->toBeFalse() + ->and($exception->getStatusCode())->toBeNull(); + }); + + it('implements PSR RequestExceptionInterface', function () { + $request = Mockery::mock(RequestInterface::class); + $exception = new HttpException('Error', $request); + + expect($exception)->toBeInstanceOf(RequestExceptionInterface::class); + }); + + it('provides detailed error context', function () { + $request = Mockery::mock(RequestInterface::class); + $request->shouldReceive('getMethod')->andReturn('POST'); + $request->shouldReceive('getUri')->andReturn(new \Nyholm\Psr7\Uri('https://api.example.com/users')); + $request->shouldReceive('getHeaders')->andReturn(['Authorization' => ['Bearer token']]); + + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('getStatusCode')->andReturn(401); + $response->shouldReceive('getReasonPhrase')->andReturn('Unauthorized'); + $response->shouldReceive('getHeaders')->andReturn(['WWW-Authenticate' => ['Bearer']]); + + $exception = new HttpException('Authentication failed', $request, $response); + $context = $exception->getContext(); + + expect($context)->toHaveKey('message') + ->and($context)->toHaveKey('request') + ->and($context)->toHaveKey('response') + ->and($context['request']['method'])->toBe('POST') + ->and($context['request']['uri'])->toBe('https://api.example.com/users') + ->and($context['response']['status_code'])->toBe(401); + }); + + it('provides context without response', function () { + $request = Mockery::mock(RequestInterface::class); + $request->shouldReceive('getMethod')->andReturn('GET'); + $request->shouldReceive('getUri')->andReturn(new \Nyholm\Psr7\Uri('https://example.com')); + $request->shouldReceive('getHeaders')->andReturn([]); + + $exception = new HttpException('Error', $request); + $context = $exception->getContext(); + + expect($context)->toHaveKey('message') + ->and($context)->toHaveKey('request') + ->and($context)->not->toHaveKey('response'); + }); + + it('can chain previous exceptions', function () { + $request = Mockery::mock(RequestInterface::class); + $previous = new \RuntimeException('Original error'); + + $exception = new HttpException('Wrapped error', $request, null, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('can have custom error code', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new HttpException('Error', $request, null, null, 1234); + + expect($exception->getCode())->toBe(1234); + }); +}); + +describe('RequestException', function () { + it('can be created with request', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new RequestException('Request failed', $request); + + expect($exception->getMessage())->toBe('Request failed') + ->and($exception->request)->toBe($request) + ->and($exception)->toBeInstanceOf(TransportException::class); + }); + + it('can chain previous exceptions', function () { + $request = Mockery::mock(RequestInterface::class); + $previous = new \RuntimeException('Network error'); + + $exception = new RequestException('Request failed', $request, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('sets error code to zero', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new RequestException('Request failed', $request); + + expect($exception->getCode())->toBe(0); + }); +}); + +describe('NetworkException', function () { + it('extends RequestException', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new NetworkException('Network timeout', $request); + + expect($exception)->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('Network timeout') + ->and($exception->request)->toBe($request); + }); + + it('can chain previous exceptions', function () { + $request = Mockery::mock(RequestInterface::class); + $previous = new \RuntimeException('Connection refused'); + + $exception = new NetworkException('Network error', $request, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); +}); + +describe('TimeoutException', function () { + it('stores timeout duration', function () { + $request = Mockery::mock(RequestInterface::class); + + $exception = new TimeoutException('Request timeout', $request, 30); + + expect($exception)->toBeInstanceOf(RequestException::class) + ->and($exception->getMessage())->toBe('Request timeout') + ->and($exception->request)->toBe($request) + ->and($exception->timeoutSeconds)->toBe(30); + }); + + it('can chain previous exceptions', function () { + $request = Mockery::mock(RequestInterface::class); + $previous = new \RuntimeException('Timeout error'); + + $exception = new TimeoutException('Request timeout', $request, 60, $previous); + + expect($exception->getPrevious())->toBe($previous) + ->and($exception->timeoutSeconds)->toBe(60); + }); +}); + +describe('BadResponseException', function () { + it('extends HttpException', function () { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $exception = new BadResponseException('Bad response', $request, $response); + + expect($exception)->toBeInstanceOf(HttpException::class) + ->and($exception->getMessage())->toBe('Bad response'); + }); +}); + +describe('ClientException', function () { + it('extends BadResponseException', function () { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $exception = new ClientException('Client error', $request, $response); + + expect($exception)->toBeInstanceOf(BadResponseException::class) + ->and($exception)->toBeInstanceOf(HttpException::class) + ->and($exception->getMessage())->toBe('Client error'); + }); +}); + +describe('ServerException', function () { + it('extends BadResponseException', function () { + $request = Mockery::mock(RequestInterface::class); + $response = Mockery::mock(ResponseInterface::class); + + $exception = new ServerException('Server error', $request, $response); + + expect($exception)->toBeInstanceOf(BadResponseException::class) + ->and($exception)->toBeInstanceOf(HttpException::class) + ->and($exception->getMessage())->toBe('Server error'); + }); +}); + +describe('SerializationException', function () { + it('extends TransportException', function () { + $exception = new SerializationException('Serialization failed'); + + expect($exception)->toBeInstanceOf(TransportException::class) + ->and($exception->getMessage())->toBe('Serialization failed'); + }); + + it('can store data and format', function () { + $exception = new SerializationException( + 'Invalid data', + '{"invalid": json}', + 18, + 'JSON' + ); + + expect($exception->data)->toBe('{"invalid": json}') + ->and($exception->dataSize)->toBe(18) + ->and($exception->format)->toBe('JSON') + ->and($exception->getMessage())->toBe('Invalid data'); + }); + + it('accepts null data', function () { + $exception = new SerializationException('Error'); + + expect($exception->data)->toBeNull() + ->and($exception->dataSize)->toBe(0) + ->and($exception->format)->toBe('unknown'); + }); + + it('can chain previous exceptions', function () { + $previous = new \RuntimeException('Parse error'); + $exception = new SerializationException( + 'Serialization failed', + '{"data": "value"}', + 16, + 'JSON', + $previous + ); + + expect($exception->getPrevious())->toBe($previous) + ->and($exception->data)->toBe('{"data": "value"}'); + }); + + it('can get truncated data', function () { + $longData = str_repeat('a', 300); + $exception = new SerializationException('Error', $longData, 300); + + $truncated = $exception->getTruncatedData(50); + + expect($truncated)->toContain('...') + ->and(strlen($truncated))->toBeLessThan(300); + }); + + it('returns full data when under max length', function () { + $shortData = 'short data'; + $exception = new SerializationException('Error', $shortData); + + $truncated = $exception->getTruncatedData(50); + + expect($truncated)->toBe($shortData); + }); + + it('returns null when no data', function () { + $exception = new SerializationException('Error'); + + expect($exception->getTruncatedData())->toBeNull(); + }); +}); + +describe('TransportException', function () { + it('is a RuntimeException', function () { + $exception = new TransportException('Transport error'); + + expect($exception)->toBeInstanceOf(\RuntimeException::class) + ->and($exception->getMessage())->toBe('Transport error'); + }); + + it('can have error code', function () { + $exception = new TransportException('Error', 500); + + expect($exception->getCode())->toBe(500); + }); + + it('can chain previous exceptions', function () { + $previous = new \Exception('Original error'); + $exception = new TransportException('Transport error', 0, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); +}); diff --git a/tests/Factory/ClientFactoryTest.php b/tests/Factory/ClientFactoryTest.php new file mode 100644 index 0000000..55cf8f1 --- /dev/null +++ b/tests/Factory/ClientFactoryTest.php @@ -0,0 +1,247 @@ +toBeInstanceOf(ClientInterface::class); + }); + + it('can create client with logger', function () { + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('debug')->once(); + + $client = ClientFactory::create($logger); + + expect($client)->toBeInstanceOf(ClientInterface::class); + }); + + it('can create client without logger', function () { + $client = ClientFactory::create(null); + + expect($client)->toBeInstanceOf(ClientInterface::class); + }); + + it('can detect Guzzle client when available', function () { + if (! class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is not installed'); + } + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('debug')->withArgs(function ($message) { + return str_contains($message, 'Guzzle') || str_contains($message, 'Symfony'); + })->once(); + + $client = ClientFactory::create($logger); + + expect($client)->toBeInstanceOf(ClientInterface::class); + }); + + it('can detect Symfony client when available', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('debug')->withArgs(function ($message) { + return str_contains($message, 'Symfony'); + })->once(); + + $client = ClientFactory::create($logger); + + expect($client)->toBeInstanceOf(ClientInterface::class); + }); + + it('createGuzzle returns Guzzle client', function () { + if (! class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is not installed'); + } + + $client = ClientFactory::createGuzzle(); + + expect($client)->toBeInstanceOf(ClientInterface::class) + ->and($client)->toBeInstanceOf(\GuzzleHttp\Client::class); + }); + + it('createGuzzle accepts configuration', function () { + if (! class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is not installed'); + } + + $config = [ + 'timeout' => 60, + 'verify' => false, + ]; + + $client = ClientFactory::createGuzzle($config); + + expect($client)->toBeInstanceOf(\GuzzleHttp\Client::class); + }); + + it('createGuzzle throws exception when not installed', function () { + if (class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is installed, cannot test failure case'); + } + + expect(fn () => ClientFactory::createGuzzle()) + ->toThrow(RuntimeException::class, 'Guzzle HTTP client is not installed'); + }); + + it('createSymfony returns Symfony client', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $client = ClientFactory::createSymfony(); + + expect($client)->toBeInstanceOf(ClientInterface::class) + ->and($client)->toBeInstanceOf(\Symfony\Component\HttpClient\Psr18Client::class); + }); + + it('createSymfony accepts options', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $options = [ + 'timeout' => 60, + 'max_redirects' => 5, + ]; + + $client = ClientFactory::createSymfony($options); + + expect($client)->toBeInstanceOf(\Symfony\Component\HttpClient\Psr18Client::class); + }); + + it('createSymfony works with empty options', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $client = ClientFactory::createSymfony([]); + + expect($client)->toBeInstanceOf(\Symfony\Component\HttpClient\Psr18Client::class); + }); + + it('createSymfony throws exception when not installed', function () { + if (class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is installed, cannot test failure case'); + } + + expect(fn () => ClientFactory::createSymfony()) + ->toThrow(RuntimeException::class, 'Symfony HTTP Client is not installed'); + }); + + it('isAvailable checks for Guzzle', function () { + $isAvailable = ClientFactory::isAvailable('guzzle'); + + expect($isAvailable)->toBeBool(); + + if (class_exists('GuzzleHttp\Client')) { + expect($isAvailable)->toBeTrue(); + } else { + expect($isAvailable)->toBeFalse(); + } + }); + + it('isAvailable checks for Symfony', function () { + $isAvailable = ClientFactory::isAvailable('symfony'); + + expect($isAvailable)->toBeBool(); + + if (class_exists('Symfony\Component\HttpClient\Psr18Client')) { + expect($isAvailable)->toBeTrue(); + } else { + expect($isAvailable)->toBeFalse(); + } + }); + + it('isAvailable is case insensitive', function () { + $guzzleUpper = ClientFactory::isAvailable('GUZZLE'); + $guzzleLower = ClientFactory::isAvailable('guzzle'); + $symfonyUpper = ClientFactory::isAvailable('SYMFONY'); + $symfonyLower = ClientFactory::isAvailable('symfony'); + + expect($guzzleUpper)->toBe($guzzleLower) + ->and($symfonyUpper)->toBe($symfonyLower); + }); + + it('isAvailable checks for custom FQCN', function () { + $isAvailable = ClientFactory::isAvailable(\stdClass::class); + + expect($isAvailable)->toBeTrue(); + + $notAvailable = ClientFactory::isAvailable('NonExistent\Class\Name'); + + expect($notAvailable)->toBeFalse(); + }); + + it('getDetectedClientName returns client class name', function () { + $clientName = ClientFactory::getDetectedClientName(); + + expect($clientName)->toBeString() + ->and(class_exists($clientName))->toBeTrue(); + }); + + it('getDetectedClientName returns Symfony when available', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $clientName = ClientFactory::getDetectedClientName(); + + // Symfony has priority over Guzzle + expect($clientName)->toBe(\Symfony\Component\HttpClient\Psr18Client::class); + }); + + it('getDetectedClientName returns Guzzle when Symfony not available', function () { + if (! class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is not installed'); + } + + if (class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony has priority, cannot test Guzzle fallback'); + } + + $clientName = ClientFactory::getDetectedClientName(); + + expect($clientName)->toBe(\GuzzleHttp\Client::class); + }); + + it('auto-detection prefers Symfony over Guzzle', function () { + if (! class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony HTTP Client is not installed'); + } + + $client = ClientFactory::create(); + + expect($client)->toBeInstanceOf(\Symfony\Component\HttpClient\Psr18Client::class); + }); + + it('falls back to Guzzle when Symfony not available', function () { + if (class_exists('Symfony\Component\HttpClient\Psr18Client')) { + $this->markTestSkipped('Symfony is installed, cannot test Guzzle fallback'); + } + + if (! class_exists('GuzzleHttp\Client')) { + $this->markTestSkipped('GuzzleHttp\Client is not installed'); + } + + $client = ClientFactory::create(); + + expect($client)->toBeInstanceOf(\GuzzleHttp\Client::class); + }); + + it('uses PSR-18 discovery as last resort', function () { + // This test will pass if any PSR-18 client is available + $client = ClientFactory::create(); + + expect($client)->toBeInstanceOf(ClientInterface::class); + }); +}); diff --git a/tests/Factory/HttpFactoryTest.php b/tests/Factory/HttpFactoryTest.php new file mode 100644 index 0000000..2daa89b --- /dev/null +++ b/tests/Factory/HttpFactoryTest.php @@ -0,0 +1,214 @@ +toBe($instance2); + }); + + it('resetInstance clears singleton', function () { + $instance1 = HttpFactory::getInstance(); + HttpFactory::resetInstance(); + $instance2 = HttpFactory::getInstance(); + + expect($instance1)->not->toBe($instance2); + }); + + it('can create request', function () { + $factory = HttpFactory::getInstance(); + $request = $factory->createRequest('GET', 'https://example.com'); + + expect($request)->toBeInstanceOf(RequestInterface::class); + }); + + it('can create response', function () { + $factory = HttpFactory::getInstance(); + $response = $factory->createResponse(); + + expect($response)->toBeInstanceOf(ResponseInterface::class) + ->and($response->getStatusCode())->toBe(200); + }); + + it('can create response with status code and reason', function () { + $factory = HttpFactory::getInstance(); + $response = $factory->createResponse(404, 'Not Found'); + + expect($response)->toBeInstanceOf(ResponseInterface::class) + ->and($response->getStatusCode())->toBe(404) + ->and($response->getReasonPhrase())->toBe('Not Found'); + }); + + it('can create URI', function () { + $factory = HttpFactory::getInstance(); + $uri = $factory->createUri('https://api.example.com/users'); + + expect($uri)->toBeInstanceOf(UriInterface::class) + ->and((string) $uri)->toBe('https://api.example.com/users'); + }); + + it('can create empty URI', function () { + $factory = HttpFactory::getInstance(); + $uri = $factory->createUri(); + + expect($uri)->toBeInstanceOf(UriInterface::class); + }); + + it('can create stream', function () { + $factory = HttpFactory::getInstance(); + $stream = $factory->createStream('test content'); + + expect($stream)->toBeInstanceOf(StreamInterface::class) + ->and($stream->getContents())->toBe('test content'); + }); + + it('can create empty stream', function () { + $factory = HttpFactory::getInstance(); + $stream = $factory->createStream(); + + expect($stream)->toBeInstanceOf(StreamInterface::class); + }); + + it('can create stream from resource', function () { + $factory = HttpFactory::getInstance(); + $resource = fopen('php://memory', 'r+'); + fwrite($resource, 'resource content'); + rewind($resource); + + $stream = $factory->createStreamFromResource($resource); + + expect($stream)->toBeInstanceOf(StreamInterface::class) + ->and($stream->getContents())->toBe('resource content'); + + fclose($resource); + }); + + it('can create stream from file', function () { + $filename = tempnam(sys_get_temp_dir(), 'test'); + file_put_contents($filename, 'file content'); + + $factory = HttpFactory::getInstance(); + $stream = $factory->createStreamFromFile($filename, 'r'); + + expect($stream)->toBeInstanceOf(StreamInterface::class) + ->and($stream->getContents())->toBe('file content'); + + unlink($filename); + }); + + it('can use custom request factory', function () { + $customRequestFactory = Mockery::mock(RequestFactoryInterface::class); + $mockRequest = Mockery::mock(RequestInterface::class); + $customRequestFactory->shouldReceive('createRequest') + ->with('POST', 'https://example.com') + ->andReturn($mockRequest); + + $factory = new HttpFactory(requestFactory: $customRequestFactory); + $request = $factory->createRequest('POST', 'https://example.com'); + + expect($request)->toBe($mockRequest); + }); + + it('can use custom response factory', function () { + $customResponseFactory = Mockery::mock(ResponseFactoryInterface::class); + $mockResponse = Mockery::mock(ResponseInterface::class); + $customResponseFactory->shouldReceive('createResponse') + ->with(201, 'Created') + ->andReturn($mockResponse); + + $factory = new HttpFactory(responseFactory: $customResponseFactory); + $response = $factory->createResponse(201, 'Created'); + + expect($response)->toBe($mockResponse); + }); + + it('can use custom uri factory', function () { + $customUriFactory = Mockery::mock(UriFactoryInterface::class); + $mockUri = Mockery::mock(UriInterface::class); + $customUriFactory->shouldReceive('createUri') + ->with('https://custom.com') + ->andReturn($mockUri); + + $factory = new HttpFactory(uriFactory: $customUriFactory); + $uri = $factory->createUri('https://custom.com'); + + expect($uri)->toBe($mockUri); + }); + + it('can use custom stream factory', function () { + $customStreamFactory = Mockery::mock(StreamFactoryInterface::class); + $mockStream = Mockery::mock(StreamInterface::class); + $customStreamFactory->shouldReceive('createStream') + ->with('custom content') + ->andReturn($mockStream); + + $factory = new HttpFactory(streamFactory: $customStreamFactory); + $stream = $factory->createStream('custom content'); + + expect($stream)->toBe($mockStream); + }); + + it('auto-detects request factory when not provided', function () { + $factory = new HttpFactory; + $request = $factory->createRequest('GET', 'https://example.com'); + + expect($request)->toBeInstanceOf(RequestInterface::class); + }); + + it('auto-detects response factory when not provided', function () { + $factory = new HttpFactory; + $response = $factory->createResponse(200); + + expect($response)->toBeInstanceOf(ResponseInterface::class); + }); + + it('auto-detects uri factory when not provided', function () { + $factory = new HttpFactory; + $uri = $factory->createUri('https://example.com'); + + expect($uri)->toBeInstanceOf(UriInterface::class); + }); + + it('auto-detects stream factory when not provided', function () { + $factory = new HttpFactory; + $stream = $factory->createStream('test'); + + expect($stream)->toBeInstanceOf(StreamInterface::class); + }); + + it('can chain multiple factory creations', function () { + $factory = HttpFactory::getInstance(); + + $request = $factory->createRequest('POST', 'https://api.example.com'); + $response = $factory->createResponse(200); + $uri = $factory->createUri('https://example.com'); + $stream = $factory->createStream('body'); + + expect($request)->toBeInstanceOf(RequestInterface::class) + ->and($response)->toBeInstanceOf(ResponseInterface::class) + ->and($uri)->toBeInstanceOf(UriInterface::class) + ->and($stream)->toBeInstanceOf(StreamInterface::class); + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/HttpClient/MockHttpClient.php b/tests/HttpClient/MockHttpClient.php index 0285176..9b10f28 100644 --- a/tests/HttpClient/MockHttpClient.php +++ b/tests/HttpClient/MockHttpClient.php @@ -93,7 +93,9 @@ public static function response( $client = static::new(); if (is_array($contents)) { - $contents = json_encode($contents); + // Use the serializer for consistent error handling + $serializer = \Farzai\Transport\Serialization\SerializerFactory::createDefault(); + $contents = $serializer->encode($contents); } return $client->createResponse($statusCode, $contents, $headers); diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php new file mode 100644 index 0000000..4990730 --- /dev/null +++ b/tests/MiddlewareTest.php @@ -0,0 +1,201 @@ +order[] = 'before-1'; + $response = $next($request); + $this->order[] = 'after-1'; + + return $response; + } + }; + + $middleware2 = new class($order) implements \Farzai\Transport\Middleware\MiddlewareInterface + { + public function __construct(private array &$order) {} + + public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface + { + $this->order[] = 'before-2'; + $response = $next($request); + $this->order[] = 'after-2'; + + return $response; + } + }; + + $stack = new MiddlewareStack([$middleware1, $middleware2]); + + $request = new Request('GET', 'https://example.com'); + $stack->handle($request, function ($req) use (&$order) { + $order[] = 'core'; + + return new Response(200); + }); + + expect($order)->toBe(['before-1', 'before-2', 'core', 'after-2', 'after-1']); + }); + + it('can modify request in middleware', function () { + $middleware = new class implements \Farzai\Transport\Middleware\MiddlewareInterface + { + public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface + { + $request = $request->withHeader('X-Modified', 'true'); + + return $next($request); + } + }; + + $stack = new MiddlewareStack([$middleware]); + + $request = new Request('GET', 'https://example.com'); + $stack->handle($request, function ($req) { + expect($req->getHeaderLine('X-Modified'))->toBe('true'); + + return new Response(200); + }); + }); + + it('can modify response in middleware', function () { + $middleware = new class implements \Farzai\Transport\Middleware\MiddlewareInterface + { + public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface + { + $response = $next($request); + + return $response->withHeader('X-Modified', 'true'); + } + }; + + $stack = new MiddlewareStack([$middleware]); + + $request = new Request('GET', 'https://example.com'); + $response = $stack->handle($request, fn () => new Response(200)); + + expect($response->getHeaderLine('X-Modified'))->toBe('true'); + }); + + it('can push middleware to stack', function () { + $middleware1 = new class implements \Farzai\Transport\Middleware\MiddlewareInterface + { + public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface + { + $request = $request->withHeader('X-First', 'true'); + + return $next($request); + } + }; + + $middleware2 = new class implements \Farzai\Transport\Middleware\MiddlewareInterface + { + public function handle(\Psr\Http\Message\RequestInterface $request, callable $next): \Psr\Http\Message\ResponseInterface + { + $request = $request->withHeader('X-Second', 'true'); + + return $next($request); + } + }; + + $stack = new MiddlewareStack; + $result = $stack->push($middleware1); + $stack->push($middleware2); + + // push should return $this for fluent API + expect($result)->toBe($stack); + + $request = new Request('GET', 'https://example.com'); + $stack->handle($request, function ($req) { + expect($req->getHeaderLine('X-First'))->toBe('true') + ->and($req->getHeaderLine('X-Second'))->toBe('true'); + + return new Response(200); + }); + }); + + it('can create empty middleware stack', function () { + $stack = new MiddlewareStack; + + $request = new Request('GET', 'https://example.com'); + $response = $stack->handle($request, fn () => new Response(200)); + + expect($response->getStatusCode())->toBe(200); + }); +}); + +describe('LoggingMiddleware', function () { + it('logs requests and responses', function () { + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('info') + ->once() + ->with(Mockery::pattern('/\[REQUEST\] GET https:\/\/example\.com/'), Mockery::type('array')); + $logger->shouldReceive('info') + ->once() + ->with(Mockery::pattern('/\[RESPONSE\] 200 https:\/\/example\.com/'), Mockery::type('array')); + + $middleware = new LoggingMiddleware($logger); + + $request = new Request('GET', 'https://example.com'); + $middleware->handle($request, fn () => new Response(200)); + }); + + it('logs errors', function () { + $logger = Mockery::mock(LoggerInterface::class); + $logger->shouldReceive('info')->once(); // Request log + $logger->shouldReceive('error') + ->once() + ->with(Mockery::pattern('/\[ERROR\] GET https:\/\/example\.com/'), Mockery::type('array')); + + $middleware = new LoggingMiddleware($logger); + + $request = new Request('GET', 'https://example.com'); + + try { + $middleware->handle($request, function () { + throw new \RuntimeException('Network error'); + }); + } catch (\RuntimeException $e) { + expect($e->getMessage())->toBe('Network error'); + } + }); +}); + +describe('TimeoutMiddleware', function () { + it('adds timeout header to request', function () { + $middleware = new TimeoutMiddleware(60); + + $request = new Request('GET', 'https://example.com'); + $middleware->handle($request, function ($req) { + expect($req->getHeaderLine('X-Timeout'))->toBe('60'); + + return new Response(200); + }); + }); + + it('can get configured timeout', function () { + $middleware = new TimeoutMiddleware(45); + + expect($middleware->getTimeout())->toBe(45); + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/RequestBuilderTest.php b/tests/RequestBuilderTest.php new file mode 100644 index 0000000..c69786a --- /dev/null +++ b/tests/RequestBuilderTest.php @@ -0,0 +1,215 @@ +toBeInstanceOf(RequestBuilder::class) + ->and($request->build()->getMethod())->toBe('GET') + ->and((string) $request->build()->getUri())->toBe('https://api.example.com/users'); + }); + + it('can create POST request', function () { + $request = RequestBuilder::post('/users'); + + expect($request->build()->getMethod())->toBe('POST'); + }); + + it('can create PUT request', function () { + $request = RequestBuilder::put('/users/123'); + + expect($request->build()->getMethod())->toBe('PUT'); + }); + + it('can create PATCH request', function () { + $request = RequestBuilder::patch('/users/123'); + + expect($request->build()->getMethod())->toBe('PATCH'); + }); + + it('can create DELETE request', function () { + $request = RequestBuilder::delete('/users/123'); + + expect($request->build()->getMethod())->toBe('DELETE'); + }); + + it('can create HEAD request', function () { + $request = RequestBuilder::head('/users'); + + expect($request->build()->getMethod())->toBe('HEAD'); + }); + + it('can create OPTIONS request', function () { + $request = RequestBuilder::options('/users'); + + expect($request->build()->getMethod())->toBe('OPTIONS'); + }); +}); + +describe('RequestBuilder fluent API', function () { + it('can set headers', function () { + $request = RequestBuilder::get('/users') + ->withHeader('Accept', 'application/json') + ->withHeader('X-Custom', 'value') + ->build(); + + expect($request->getHeaderLine('Accept'))->toBe('application/json') + ->and($request->getHeaderLine('X-Custom'))->toBe('value'); + }); + + it('can set multiple headers at once', function () { + $request = RequestBuilder::get('/users') + ->withHeaders([ + 'Accept' => 'application/json', + 'X-Custom' => 'value', + ]) + ->build(); + + expect($request->getHeaderLine('Accept'))->toBe('application/json') + ->and($request->getHeaderLine('X-Custom'))->toBe('value'); + }); + + it('can set JSON body', function () { + $data = ['name' => 'John', 'email' => 'john@example.com']; + + $request = RequestBuilder::post('/users') + ->withJson($data) + ->build(); + + expect($request->getBody()->getContents())->toBe(json_encode($data)) + ->and($request->getHeaderLine('Content-Type'))->toBe('application/json'); + }); + + it('can set form data body', function () { + $data = ['username' => 'john', 'password' => 'secret']; + + $request = RequestBuilder::post('/login') + ->withForm($data) + ->build(); + + expect($request->getBody()->getContents())->toBe(http_build_query($data)) + ->and($request->getHeaderLine('Content-Type'))->toBe('application/x-www-form-urlencoded'); + }); + + it('can set query parameters', function () { + $request = RequestBuilder::get('/users') + ->withQuery(['page' => 1, 'limit' => 10]) + ->build(); + + expect($request->getUri()->getQuery())->toBe('page=1&limit=10'); + }); + + it('can merge query parameters', function () { + $request = RequestBuilder::get('/users?sort=name') + ->withQuery(['page' => 1]) + ->withQuery(['limit' => 10]) + ->build(); + + $query = $request->getUri()->getQuery(); + expect($query)->toContain('sort=name') + ->and($query)->toContain('page=1') + ->and($query)->toContain('limit=10'); + }); + + it('can set basic authentication', function () { + $request = RequestBuilder::get('/protected') + ->withBasicAuth('user', 'pass') + ->build(); + + $expected = 'Basic '.base64_encode('user:pass'); + expect($request->getHeaderLine('Authorization'))->toBe($expected); + }); + + it('can set bearer token', function () { + $request = RequestBuilder::get('/protected') + ->withBearerToken('my-token-123') + ->build(); + + expect($request->getHeaderLine('Authorization'))->toBe('Bearer my-token-123'); + }); + + it('is immutable', function () { + $builder = RequestBuilder::get('/users'); + $builder2 = $builder->withHeader('Accept', 'application/json'); + + expect($builder)->not->toBe($builder2); + }); + + it('can set body with StreamInterface', function () { + $stream = \Farzai\Transport\Factory\HttpFactory::getInstance() + ->createStream('test body content'); + + $request = RequestBuilder::post('/users') + ->withBody($stream) + ->build(); + + expect($request->getBody()->getContents())->toBe('test body content'); + }); + + it('throws exception when sending without transport', function () { + $builder = RequestBuilder::get('/users'); + + expect(fn () => $builder->send()) + ->toThrow(RuntimeException::class, 'No transport instance available'); + }); +}); + +describe('RequestBuilder with Transport', function () { + it('can send request through transport', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->andReturn(new GuzzleResponse(200, [], '{"success":true}')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport + ->get('/users') + ->withHeader('Accept', 'application/json') + ->send(); + + expect($response->isSuccessful())->toBeTrue() + ->and($response->json())->toBe(['success' => true]); + }); + + it('can chain multiple operations', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + return $request->getMethod() === 'POST' + && $request->getHeaderLine('Content-Type') === 'application/json' + && $request->getHeaderLine('Accept') === 'application/json' + && str_contains($request->getUri()->getQuery(), 'format=json'); + })) + ->andReturn(new GuzzleResponse(201, [], '{"id":123}')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport + ->post('/users') + ->withJson(['name' => 'John']) + ->withHeader('Accept', 'application/json') + ->withQuery(['format' => 'json']) + ->send(); + + expect($response->statusCode())->toBe(201); + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/RequestTest.php b/tests/RequestTest.php deleted file mode 100644 index 5ae3100..0000000 --- a/tests/RequestTest.php +++ /dev/null @@ -1,151 +0,0 @@ -getMethod())->toBe('GET'); -}); - -it('can set the request method', function () { - $request = new Request('GET', new Uri('https://example.com')); - - $newRequest = $request->withMethod('POST'); - - expect($newRequest->getMethod())->toBe('POST'); -}); - -it('can get request target', function () { - $request = new Request('GET', new Uri('https://example.com/foo')); - - expect($request->getRequestTarget())->toBe('/foo'); -}); - -it('can set request target', function () { - $request = new Request('GET', new Uri('https://example.com')); - - $newRequest = $request->withRequestTarget('/foo'); - - expect($newRequest->getRequestTarget())->toBe('/foo'); -}); - -it('can get the request URI', function () { - $uri = new Uri('https://example.com'); - $request = new Request('GET', $uri); - - expect($request->getUri())->toBe($uri); -}); - -it('can set the request URI', function () { - $uri = new Uri('https://example.com'); - $request = new Request('GET', $uri); - - $newUri = new Uri('https://example.org'); - $newRequest = $request->withUri($newUri); - - expect($newRequest->getUri())->toBe($newUri); -}); - -it('can get the request protocol version', function () { - $request = new Request('GET', new Uri('https://example.com')); - - expect($request->getProtocolVersion())->toBe('1.1'); -}); - -it('can set the request protocol version', function () { - $request = new Request('GET', new Uri('https://example.com')); - - $newRequest = $request->withProtocolVersion('2.0'); - - expect($newRequest->getProtocolVersion())->toBe('2.0'); -}); - -it('can get the request headers', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - ]); - - expect($request->getHeaders())->toBe([ - 'Host' => ['example.com'], - 'Content-Type' => ['application/json'], - ]); -}); - -it('can set a request header', function () { - $request = new Request('GET', new Uri('https://example.com')); - - $newRequest = $request->withHeader('Content-Type', 'application/json'); - - expect($newRequest->getHeaders())->toBe([ - 'Host' => ['example.com'], - 'Content-Type' => ['application/json'], - ]); -}); - -it('can add a request header', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - ]); - - $newRequest = $request->withAddedHeader('Accept', 'application/json'); - - expect($newRequest->getHeaders())->toBe([ - 'Host' => ['example.com'], - 'Content-Type' => ['application/json'], - 'Accept' => ['application/json'], - ]); -}); - -it('can check exists header', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - ]); - - expect($request->hasHeader('Content-Type'))->toBeTrue(); - expect($request->hasHeader('Accept'))->toBeFalse(); -}); - -it('can get header', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - ]); - - expect($request->getHeader('Content-Type'))->toBe(['application/json']); - expect($request->getHeader('Accept'))->toBe([]); -}); - -it('can remove a request header', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - 'Accept' => 'application/json', - ]); - - $newRequest = $request->withoutHeader('Accept'); - - expect($newRequest->getHeaders())->toBe([ - 'Host' => ['example.com'], - 'Content-Type' => ['application/json'], - ]); -}); - -it('can get a request header line', function () { - $request = new Request('GET', new Uri('https://example.com'), [ - 'Content-Type' => 'application/json', - ]); - - expect($request->getHeaderLine('Content-Type'))->toBe('application/json'); -}); - -it('can set the request body', function () { - $request = new Request('GET', new Uri('https://example.com')); - - $stream = $this->createMock(StreamInterface::class); - $stream->method('__toString')->willReturn('{"foo":"bar"}'); - - $newRequest = $request->withBody($stream); - - expect((string) $newRequest->getBody())->toBe('{"foo":"bar"}'); -}); diff --git a/tests/ResponseExceptionFactoryTest.php b/tests/ResponseExceptionFactoryTest.php new file mode 100644 index 0000000..2a2ede0 --- /dev/null +++ b/tests/ResponseExceptionFactoryTest.php @@ -0,0 +1,319 @@ +request = Mockery::mock(RequestInterface::class); + $this->psrResponse = Mockery::mock(PsrResponseInterface::class); + }); + + it('creates ClientException for 4xx status codes', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(404); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Not Found'); + $response->shouldReceive('getReasonPhrase')->andReturn('Not Found'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception)->toBeInstanceOf(ClientException::class) + ->and($exception)->toBeInstanceOf(HttpException::class); + }); + + it('creates ClientException for all 4xx codes', function () { + $codes = [400, 401, 403, 404, 422, 429, 499]; + + foreach ($codes as $code) { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn($code); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Error'); + $response->shouldReceive('getReasonPhrase')->andReturn('Error'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception)->toBeInstanceOf(ClientException::class); + } + }); + + it('creates ServerException for 5xx status codes', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(500); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Internal Server Error'); + $response->shouldReceive('getReasonPhrase')->andReturn('Internal Server Error'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception)->toBeInstanceOf(ServerException::class) + ->and($exception)->toBeInstanceOf(HttpException::class); + }); + + it('creates ServerException for all 5xx codes', function () { + $codes = [500, 501, 502, 503, 504, 599]; + + foreach ($codes as $code) { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn($code); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Error'); + $response->shouldReceive('getReasonPhrase')->andReturn('Error'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception)->toBeInstanceOf(ServerException::class); + } + }); + + it('creates BadResponseException for non-2xx, non-4xx, non-5xx codes', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(300); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Redirect'); + $response->shouldReceive('getReasonPhrase')->andReturn('Multiple Choices'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception)->toBeInstanceOf(BadResponseException::class) + ->and($exception)->toBeInstanceOf(HttpException::class); + }); + + it('can chain previous exception', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(404); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Not Found'); + $response->shouldReceive('getReasonPhrase')->andReturn('Not Found'); + + $previous = new \RuntimeException('Previous error'); + $exception = ResponseExceptionFactory::create($response, $previous); + + expect($exception->getPrevious())->toBe($previous); + }); + + it('extracts error message from JSON message field', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['message' => 'User not found']); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('User not found'); + }); + + it('extracts error message from JSON error field', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['error' => 'Invalid credentials']); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Invalid credentials'); + }); + + it('extracts error message from JSON error_message field', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['error_message' => 'Authentication failed']); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Authentication failed'); + }); + + it('extracts error message from JSON error_msg field', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['error_msg' => 'Request timeout']); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Request timeout'); + }); + + it('extracts error message from JSON error_description field', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['error_description' => 'Token expired']); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Token expired'); + }); + + it('handles array of errors in JSON', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn([ + 'errors' => ['Name is required', 'Email is invalid'], + ]); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Name is required; Email is invalid'); + }); + + it('filters empty values from error arrays', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn([ + 'errors' => ['Valid error', '', null, 'Another error'], + ]); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Valid error; Another error'); + }); + + it('skips non-string error values', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn([ + 'error' => 12345, // Not a string + ]); + $response->shouldReceive('body')->andReturn('Numeric error'); + $response->shouldReceive('getReasonPhrase')->andReturn('Bad Request'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Numeric error'); + }); + + it('skips empty string error values', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn([ + 'message' => '', + 'error' => 'Actual error', + ]); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Actual error'); + }); + + it('falls back to response body when JSON has no valid errors', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(['data' => 'some data']); + $response->shouldReceive('body')->andReturn('Plain text error'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Plain text error'); + }); + + it('uses response body for non-JSON responses', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('HTML error page'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('HTML error page'); + }); + + it('truncates long response bodies', function () { + $longBody = str_repeat('a', 600); + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn($longBody); + $response->shouldReceive('statusCode')->andReturn(500); + $response->shouldReceive('getReasonPhrase')->andReturn('Internal Server Error'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + // Should fall back to HTTP status message when body is too long + expect($message)->toBe('HTTP 500 Internal Server Error'); + }); + + it('uses HTTP status description when body is empty', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn(''); + $response->shouldReceive('statusCode')->andReturn(404); + $response->shouldReceive('getReasonPhrase')->andReturn('Not Found'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('HTTP 404 Not Found'); + }); + + it('handles JSON parsing errors gracefully', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andThrow(new \Exception('Invalid JSON')); + $response->shouldReceive('body')->andReturn('Error text'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Error text'); + }); + + it('handles non-array JSON responses', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn('string response'); + $response->shouldReceive('body')->andReturn('Error text'); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + expect($message)->toBe('Error text'); + }); + + it('respects error field priority', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('jsonOrNull')->andReturn([ + 'error' => 'Lower priority', + 'message' => 'Higher priority', + 'error_description' => 'Lowest priority', + ]); + + $message = ResponseExceptionFactory::getErrorMessage($response); + + // 'message' has highest priority + expect($message)->toBe('Higher priority'); + }); + + it('includes status code in exception', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(403); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(null); + $response->shouldReceive('body')->andReturn('Forbidden'); + $response->shouldReceive('getReasonPhrase')->andReturn('Forbidden'); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception->getCode())->toBe(403); + }); + + it('creates exception with extracted message', function () { + $response = Mockery::mock(ResponseInterface::class); + $response->shouldReceive('statusCode')->andReturn(400); + $response->shouldReceive('getPsrRequest')->andReturn($this->request); + $response->shouldReceive('jsonOrNull')->andReturn(['message' => 'Validation failed']); + + $exception = ResponseExceptionFactory::create($response); + + expect($exception->getMessage())->toBe('Validation failed'); + }); +}); diff --git a/tests/ResponseExceptionTest.php b/tests/ResponseExceptionTest.php deleted file mode 100644 index 5b6ec29..0000000 --- a/tests/ResponseExceptionTest.php +++ /dev/null @@ -1,53 +0,0 @@ -createMock(PsrStreamInterface::class); - $stream->method('getContents')->willReturn('{"message":"Bad Request"}'); - - $response = $this->createMock(PsrResponseInterface::class); - $response->method('getStatusCode')->willReturn(400); - $response->method('getBody')->willReturn($stream); - $response - ->method('getHeaders') - ->willReturn(['Content-Type' => ['application/json']]); - - $psrRequest = $this->createMock(PsrRequestInterface::class); - - $exception = ResponseExceptionFactory::create( - new Response($psrRequest, $response) - ); - - expect($exception)->toBeInstanceOf(BadResponseException::class); - expect($exception->getMessage())->toBe('Bad Request'); - expect($exception->getCode())->toBe(400); -}); - -it('should throw server error', function () { - $stream = $this->createMock(PsrStreamInterface::class); - $stream->method('getContents')->willReturn('{"message":"Internal Server Error"}'); - - $response = $this->createMock(PsrResponseInterface::class); - $response->method('getStatusCode')->willReturn(500); - $response->method('getBody')->willReturn($stream); - $response - ->method('getHeaders') - ->willReturn(['Content-Type' => ['application/json']]); - - $psrRequest = $this->createMock(PsrRequestInterface::class); - - $exception = ResponseExceptionFactory::create( - new Response($psrRequest, $response) - ); - - expect($exception)->toBeInstanceOf(ServerException::class); - expect($exception->getMessage())->toBe('Internal Server Error'); - expect($exception->getCode())->toBe(500); -}); diff --git a/tests/ResponseTest.php b/tests/ResponseTest.php index 9790b78..b7a07df 100644 --- a/tests/ResponseTest.php +++ b/tests/ResponseTest.php @@ -1,189 +1,538 @@ createMock(RequestInterface::class); +describe('Response', function () { + it('can get response status code', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(200); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getStatusCode')->willReturn(200); + $response = new Response($request, $psrResponse); - $response = new Response($baseRequest, $baseResponse); + expect($response->statusCode())->toBe(200) + ->and($response->isSuccessful())->toBeTrue(); + }); - expect($response->statusCode())->toBe(200); - expect($response->isSuccessfull())->toBeTrue(); -}); + it('can get response body', function () { + $request = Mockery::mock(RequestInterface::class); -it('can get the response body', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"foo":"bar"}'); + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"foo":"bar"}'); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getBody')->willReturn($stream); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $response = new Response($request, $psrResponse); - expect($response->body())->toBe('{"foo":"bar"}'); -}); + expect($response->body())->toBe('{"foo":"bar"}'); + }); -it('can get the response headers', function () { - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse - ->method('getHeaders') - ->willReturn(['Content-Type' => ['application/json']]); + it('can get response headers', function () { + $request = Mockery::mock(RequestInterface::class); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getHeaders') + ->andReturn(['Content-Type' => ['application/json']]); - expect($response->headers())->toBe([ - 'Content-Type' => ['application/json'], - ]); -}); + $response = new Response($request, $psrResponse); -it('can check if the response is not successfull', function () { - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getStatusCode')->willReturn(400); + expect($response->headers())->toBe([ + 'Content-Type' => ['application/json'], + ]); + }); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + it('identifies successful 2xx responses', function () { + $request = Mockery::mock(RequestInterface::class); - expect($response->isSuccessfull())->toBeFalse(); - expect($response->statusCode())->toBe(400); -}); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(200); + $response = new Response($request, $psrResponse); + expect($response->isSuccessful())->toBeTrue(); + }); + + it('identifies edge of successful range (299)', function () { + $request = Mockery::mock(RequestInterface::class); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(299); + $response = new Response($request, $psrResponse); + expect($response->isSuccessful())->toBeTrue(); + }); -it('can get json body as array', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"foo":"bar"}'); + it('identifies 4xx as unsuccessful', function () { + $request = Mockery::mock(RequestInterface::class); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getBody')->willReturn($stream); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(400); + $response = new Response($request, $psrResponse); + expect($response->isSuccessful())->toBeFalse(); + }); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + it('identifies 5xx as unsuccessful', function () { + $request = Mockery::mock(RequestInterface::class); - expect($response->json())->toBe(['foo' => 'bar']); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(500); + $response = new Response($request, $psrResponse); + expect($response->isSuccessful())->toBeFalse(); + }); }); -it('can get json body with dot notation', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"foo":{"bar":"baz"}}'); +describe('Response JSON parsing', function () { + it('can parse valid JSON', function () { + $request = Mockery::mock(RequestInterface::class); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getBody')->willReturn($stream); + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"foo":"bar"}'); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); - expect($response->json('foo.bar'))->toBe('baz'); -}); + $response = new Response($request, $psrResponse); -it('cannot get json when invalid json format', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"foo":"bar"'); + expect($response->json())->toBe(['foo' => 'bar']); + }); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getBody')->willReturn($stream); + it('can get nested JSON values using dot notation', function () { + $request = Mockery::mock(RequestInterface::class); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"user":{"name":"John","address":{"city":"NYC"}}}'); - expect($response->json())->toBeNull(); -}); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + expect($response->json('user.name'))->toBe('John') + ->and($response->json('user.address.city'))->toBe('NYC'); + }); + + it('throws exception on invalid JSON', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"foo":"bar"'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + $response->json(); + })->throws(JsonParseException::class); + + it('can safely parse JSON with jsonOrNull', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"foo":"bar"'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + expect($response->jsonOrNull())->toBeNull(); + }); + + it('returns null for empty body', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn(''); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + expect($response->json())->toBeNull(); + }); + + it('can convert response to array', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"foo":"bar","items":[1,2,3]}'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + expect($response->toArray())->toBe([ + 'foo' => 'bar', + 'items' => [1, 2, 3], + ]); + }); + + it('caches parsed JSON', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->once()->andReturn('{"foo":"bar"}'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->once()->andReturn($stream); + + $response = new Response($request, $psrResponse); + + // First call parses + $response->json(); + // Second call should use cache + $result = $response->json(); + + expect($result)->toBe(['foo' => 'bar']); + }); + + it('returns null when getting key from non-array JSON data', function () { + $request = Mockery::mock(RequestInterface::class); + + // Use a serializer configured to return objects + $config = \Farzai\Transport\Serialization\JsonConfig::default()->withAssociative(false); + $serializer = new \Farzai\Transport\Serialization\JsonSerializer($config); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"name":"John"}'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse, $serializer); + + // When JSON is parsed as object and we try to get a key, it should return null + $result = $response->json('name'); + + expect($result)->toBeNull(); + }); + + it('returns empty array when toArray is called on non-array JSON', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('"just a string"'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + $result = $response->toArray(); + + expect($result)->toBe([]); + }); + + it('returns empty array when toArray is called on integer JSON', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('123'); -it('can specify key name to get json body', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"foo":"bar"}'); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getBody')->willReturn($stream); + $response = new Response($request, $psrResponse); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $result = $response->toArray(); - expect($response->json('foo'))->toBe('bar'); + expect($result)->toBe([]); + }); }); -it('can throw error if response status is not success', function () { - $stream = $this->createMock(StreamInterface::class); - $stream->method('getContents')->willReturn('{"error": "invalid_request"}'); +describe('Response error handling', function () { + it('can throw exception on non-successful response', function () { + $request = Mockery::mock(RequestInterface::class); - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getStatusCode')->willReturn(400); - $baseResponse->method('getBody')->willReturn($stream); + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"error":"Not found"}'); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(404); + $psrResponse->shouldReceive('getBody')->andReturn($stream); - $response->throw(); -})->throws(\GuzzleHttp\Exception\BadResponseException::class); + $response = new Response($request, $psrResponse); -it('should not throw error if response status is success', function () { - $baseResponse = $this->createMock(ResponseInterface::class); - $baseResponse->method('getStatusCode')->willReturn(200); + $response->throw(); + })->throws(\Farzai\Transport\Exceptions\ClientException::class); - $response = new Response( - $this->createMock(RequestInterface::class), - $baseResponse - ); + it('does not throw on successful response', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(200); - $response->throw(); + $response = new Response($request, $psrResponse); - expect($response->isSuccessfull())->toBeTrue(); + $result = $response->throw(); + + expect($result)->toBe($response); + }); + + it('can use custom error callback', function () { + $request = Mockery::mock(RequestInterface::class); + + $stream = Mockery::mock(StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn('{"error":"Not found"}'); + + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(404); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + $response->throw(function ($resp, $exception) { + if ($resp->statusCode() === 404) { + throw new \RuntimeException('Custom not found error'); + } + }); + })->throws(\RuntimeException::class, 'Custom not found error'); }); -it('should create response via factory success', function () { - $response = ResponseFactory::create( - 200, - ['Content-Type' => ['application/json']], - '{"foo":"bar"}' - ); - - expect($response->getStatusCode())->toBe(200); - expect($response->getBody()->getContents())->toBe('{"foo":"bar"}'); - expect($response->getHeaders())->toBe([ - 'Content-Type' => ['application/json'], - ]); +describe('ResponseBuilder', function () { + it('can build PSR response', function () { + $response = ResponseBuilder::create() + ->statusCode(200) + ->withHeader('Content-Type', 'application/json') + ->withBody('{"success":true}') + ->build(); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getBody()->getContents())->toBe('{"success":true}') + ->and($response->getHeaders())->toBe([ + 'Content-Type' => ['application/json'], + ]); + }); + + it('can build response with multiple headers', function () { + $response = ResponseBuilder::create() + ->statusCode(201) + ->withHeaders([ + 'Content-Type' => 'application/json', + 'X-Request-ID' => '12345', + ]) + ->withBody('{"id":123}') + ->build(); + + expect($response->getStatusCode())->toBe(201) + ->and($response->getHeaders())->toHaveKey('Content-Type') + ->and($response->getHeaders())->toHaveKey('X-Request-ID'); + }); + + it('can build response with version and reason', function () { + $response = ResponseBuilder::create() + ->statusCode(404) + ->withVersion('1.1') + ->withReason('Not Found') + ->build(); + + expect($response->getStatusCode())->toBe(404) + ->and($response->getProtocolVersion())->toBe('1.1') + ->and($response->getReasonPhrase())->toBe('Not Found'); + }); + + it('can build response with StreamInterface body', function () { + $stream = \Farzai\Transport\Factory\HttpFactory::getInstance() + ->createStream('stream body content'); + + $response = ResponseBuilder::create() + ->statusCode(200) + ->withBody($stream) + ->build(); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getBody()->getContents())->toBe('stream body content'); + }); + + it('can build response with custom protocol version', function () { + $response = ResponseBuilder::create() + ->statusCode(200) + ->withVersion('2.0') + ->withBody('HTTP/2 response') + ->build(); + + expect($response->getProtocolVersion())->toBe('2.0') + ->and($response->getBody()->getContents())->toBe('HTTP/2 response'); + }); +}); + +describe('Response PSR-7 implementation', function () { + it('implements getStatusCode', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getStatusCode')->andReturn(200); + + $response = new Response($request, $psrResponse); + + expect($response->getStatusCode())->toBe(200); + }); + + it('implements getReasonPhrase', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getReasonPhrase')->andReturn('OK'); + + $response = new Response($request, $psrResponse); + + expect($response->getReasonPhrase())->toBe('OK'); + }); + + it('implements getProtocolVersion', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getProtocolVersion')->andReturn('1.1'); + + $response = new Response($request, $psrResponse); + + expect($response->getProtocolVersion())->toBe('1.1'); + }); + + it('implements hasHeader', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('hasHeader')->with('Content-Type')->andReturn(true); + + $response = new Response($request, $psrResponse); + + expect($response->hasHeader('Content-Type'))->toBeTrue(); + }); + + it('implements getHeader', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getHeader')->with('Content-Type')->andReturn(['application/json']); + + $response = new Response($request, $psrResponse); + + expect($response->getHeader('Content-Type'))->toBe(['application/json']); + }); + + it('implements getHeaderLine', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getHeaderLine')->with('Content-Type')->andReturn('application/json'); + + $response = new Response($request, $psrResponse); + + expect($response->getHeaderLine('Content-Type'))->toBe('application/json'); + }); + + it('implements getBody', function () { + $request = Mockery::mock(RequestInterface::class); + $stream = Mockery::mock(StreamInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('getBody')->andReturn($stream); + + $response = new Response($request, $psrResponse); + + expect($response->getBody())->toBe($stream); + }); + + it('implements withProtocolVersion', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('withProtocolVersion')->with('2.0')->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withProtocolVersion('2.0'); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('implements withHeader', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('withHeader')->with('X-Custom', 'value')->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withHeader('X-Custom', 'value'); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('implements withAddedHeader', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('withAddedHeader')->with('Set-Cookie', 'value')->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withAddedHeader('Set-Cookie', 'value'); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('implements withoutHeader', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('withoutHeader')->with('X-Deprecated')->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withoutHeader('X-Deprecated'); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('implements withBody', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $stream = Mockery::mock(StreamInterface::class); + $psrResponse->shouldReceive('withBody')->with($stream)->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withBody($stream); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('implements withStatus', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + $newPsrResponse = Mockery::mock(PsrResponseInterface::class); + $psrResponse->shouldReceive('withStatus')->with(404, 'Not Found')->andReturn($newPsrResponse); + + $response = new Response($request, $psrResponse); + $newResponse = $response->withStatus(404, 'Not Found'); + + expect($newResponse)->not->toBe($response) + ->and($newResponse)->toBeInstanceOf(Response::class); + }); + + it('can get serializer', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + + $response = new Response($request, $psrResponse); + + expect($response->getSerializer())->toBeInstanceOf(\Farzai\Transport\Contracts\SerializerInterface::class); + }); + + it('can get PSR request', function () { + $request = Mockery::mock(RequestInterface::class); + $psrResponse = Mockery::mock(PsrResponseInterface::class); + + $response = new Response($request, $psrResponse); + + expect($response->getPsrRequest())->toBe($request); + }); }); -it('should create response via builder success', function () { - $response = ResponseBuilder::create() - ->statusCode(200) - ->withHeader('Content-Type', 'application/json') - ->withHeaders(['Accept' => 'application/json']) - ->withBody('{"foo":"bar"}') - ->withVersion('1.1') - ->withReason('OK') - ->build(); - - expect($response->getStatusCode())->toBe(200); - expect($response->getBody()->getContents())->toBe('{"foo":"bar"}'); - expect($response->getHeaders())->toBe([ - 'Content-Type' => ['application/json'], - 'Accept' => ['application/json'], - ]); - - expect($response->getProtocolVersion())->toBe('1.1'); - expect($response->getReasonPhrase())->toBe('OK'); +afterEach(function () { + Mockery::close(); }); diff --git a/tests/RetryTest.php b/tests/RetryTest.php new file mode 100644 index 0000000..d1b89b7 --- /dev/null +++ b/tests/RetryTest.php @@ -0,0 +1,246 @@ +getDelay($context))->toBe(1000); + + $context = new RetryContext($request, 1, 3); + expect($strategy->getDelay($context))->toBe(1000); + + $context = new RetryContext($request, 2, 3); + expect($strategy->getDelay($context))->toBe(1000); + }); + + it('ExponentialBackoffStrategy increases delay exponentially', function () { + $strategy = new ExponentialBackoffStrategy( + baseDelayMs: 1000, + multiplier: 2.0, + maxDelayMs: 30000, + useJitter: false // Disable jitter for predictable testing + ); + + $request = new Request('GET', 'https://example.com'); + + $context = new RetryContext($request, 0, 3); + expect($strategy->getDelay($context))->toBe(1000); // 1000 * (2^0) = 1000 + + $context = new RetryContext($request, 1, 3); + expect($strategy->getDelay($context))->toBe(2000); // 1000 * (2^1) = 2000 + + $context = new RetryContext($request, 2, 3); + expect($strategy->getDelay($context))->toBe(4000); // 1000 * (2^2) = 4000 + }); + + it('ExponentialBackoffStrategy respects max delay', function () { + $strategy = new ExponentialBackoffStrategy( + baseDelayMs: 1000, + multiplier: 2.0, + maxDelayMs: 5000, + useJitter: false + ); + + $request = new Request('GET', 'https://example.com'); + + $context = new RetryContext($request, 10, 15); + $delay = $strategy->getDelay($context); + + expect($delay)->toBeLessThanOrEqual(5000); + }); + + it('ExponentialBackoffStrategy with jitter produces variable delays', function () { + $strategy = new ExponentialBackoffStrategy( + baseDelayMs: 1000, + multiplier: 2.0, + maxDelayMs: 30000, + useJitter: true + ); + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 1, 3); + + $delay1 = $strategy->getDelay($context); + $delay2 = $strategy->getDelay($context); + + // With jitter, delays should vary (though they might occasionally be equal) + // The delay should be between 0 and 2000 (base * multiplier^attempt) + expect($delay1)->toBeGreaterThanOrEqual(0) + ->and($delay1)->toBeLessThanOrEqual(2000); + }); +}); + +describe('RetryCondition', function () { + it('default condition retries on any exception', function () { + $condition = RetryCondition::default(); + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + expect($condition->shouldRetry(new \RuntimeException, $context))->toBeTrue() + ->and($condition->shouldRetry(new \Exception, $context))->toBeTrue(); + }); + + it('can retry on specific exception types', function () { + $condition = new RetryCondition; + $condition->onExceptions([\RuntimeException::class]); + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + expect($condition->shouldRetry(new \RuntimeException, $context))->toBeTrue() + ->and($condition->shouldRetry(new \LogicException, $context))->toBeFalse(); + }); + + it('does not retry when no retries left', function () { + $condition = RetryCondition::default(); + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 3, 3); // At max attempts + + expect($condition->shouldRetry(new \RuntimeException, $context))->toBeFalse(); + }); + + it('can use custom condition callback', function () { + $condition = new RetryCondition; + $condition->when(function ($exception, $context) { + return $exception->getMessage() === 'retryable'; + }); + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + expect($condition->shouldRetry(new \RuntimeException('retryable'), $context))->toBeTrue() + ->and($condition->shouldRetry(new \RuntimeException('fatal'), $context))->toBeFalse(); + }); + + it('does not retry when no conditions are set', function () { + $condition = new RetryCondition; // No conditions added + + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + // Should return false since there are no conditions + expect($condition->shouldRetry(new \RuntimeException('error'), $context))->toBeFalse(); + }); +}); + +describe('RetryContext', function () { + it('tracks retry attempts', function () { + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + expect($context->attempt)->toBe(0) + ->and($context->maxAttempts)->toBe(3) + ->and($context->hasRetriesLeft())->toBeTrue() + ->and($context->retriesLeft())->toBe(3); + }); + + it('can create next attempt context', function () { + $request = new Request('GET', 'https://example.com'); + $context = new RetryContext($request, 0, 3); + + $exception = new \RuntimeException('error'); + $nextContext = $context->nextAttempt($exception, 1000); + + expect($nextContext->attempt)->toBe(1) + ->and($nextContext->lastException)->toBe($exception) + ->and($nextContext->delaysUsed)->toBe([1000]) + ->and($nextContext->exceptions)->toBe([$exception]); + }); +}); + +describe('RetryMiddleware', function () { + it('retries on failure', function () { + $attempts = 0; + $strategy = new FixedDelayStrategy(0); // No delay for faster tests + $condition = RetryCondition::default(); + + $middleware = new RetryMiddleware(3, $strategy, $condition); + + $request = new Request('GET', 'https://example.com'); + + $response = $middleware->handle($request, function () use (&$attempts) { + $attempts++; + if ($attempts < 3) { + throw new \RuntimeException('Temporary error'); + } + + return new Response(200); + }); + + expect($attempts)->toBe(3) + ->and($response->getStatusCode())->toBe(200); + }); + + it('throws RetryExhaustedException when retries exhausted', function () { + $strategy = new FixedDelayStrategy(0); + $condition = RetryCondition::default(); + + $middleware = new RetryMiddleware(2, $strategy, $condition); + + $request = new Request('GET', 'https://example.com'); + + $middleware->handle($request, function () { + throw new \RuntimeException('Persistent error'); + }); + })->throws(RetryExhaustedException::class); + + it('provides retry context in exception', function () { + $strategy = new FixedDelayStrategy(0); + $condition = RetryCondition::default(); + + $middleware = new RetryMiddleware(2, $strategy, $condition); + + $request = new Request('GET', 'https://example.com'); + + try { + $middleware->handle($request, function () { + throw new \RuntimeException('Persistent error'); + }); + expect(false)->toBeTrue(); // Should not reach here + } catch (RetryExhaustedException $e) { + expect($e->getAttempts())->toBe(2) + ->and($e->getRetryExceptions())->toHaveCount(2) + ->and($e->getDelaysUsed())->toHaveCount(2); + } + }); + + it('does not retry when condition not met', function () { + $attempts = 0; + $strategy = new FixedDelayStrategy(0); + $condition = new RetryCondition; + $condition->onExceptions([\LogicException::class]); // Only retry LogicException + + $middleware = new RetryMiddleware(3, $strategy, $condition); + + $request = new Request('GET', 'https://example.com'); + + try { + $middleware->handle($request, function () use (&$attempts) { + $attempts++; + throw new \RuntimeException('Error'); // Different exception type + }); + } catch (\RuntimeException $e) { + expect($attempts)->toBe(1); // No retries + } + }); +}); + +afterEach(function () { + Mockery::close(); +}); diff --git a/tests/Serialization/JsonConfigTest.php b/tests/Serialization/JsonConfigTest.php new file mode 100644 index 0000000..3c2b50b --- /dev/null +++ b/tests/Serialization/JsonConfigTest.php @@ -0,0 +1,93 @@ +maxDepth)->toBe(512) + ->and($config->encodeFlags)->toBe(JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + ->and($config->decodeFlags)->toBe(JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING) + ->and($config->associative)->toBeTrue(); + }); + + it('creates strict configuration', function () { + $config = JsonConfig::strict(); + + expect($config->maxDepth)->toBe(128) + ->and($config->encodeFlags & JSON_THROW_ON_ERROR)->toBeGreaterThan(0) + ->and($config->decodeFlags & JSON_THROW_ON_ERROR)->toBeGreaterThan(0); + }); + + it('creates lenient configuration', function () { + $config = JsonConfig::lenient(); + + expect($config->maxDepth)->toBe(1024) + ->and($config->encodeFlags & JSON_INVALID_UTF8_SUBSTITUTE)->toBeGreaterThan(0); + }); + + it('creates pretty-print configuration', function () { + $config = JsonConfig::prettyPrint(); + + expect($config->encodeFlags & JSON_PRETTY_PRINT)->toBeGreaterThan(0); + }); + + it('is immutable', function () { + $config1 = JsonConfig::default(); + $config2 = $config1->withMaxDepth(256); + + expect($config1)->not->toBe($config2) + ->and($config1->maxDepth)->toBe(512) + ->and($config2->maxDepth)->toBe(256); + }); + + it('can modify max depth', function () { + $config = JsonConfig::default()->withMaxDepth(256); + + expect($config->maxDepth)->toBe(256); + }); + + it('can modify encode flags', function () { + $config = JsonConfig::default()->withEncodeFlags(JSON_PRETTY_PRINT); + + expect($config->encodeFlags)->toBe(JSON_PRETTY_PRINT); + }); + + it('can modify decode flags', function () { + $config = JsonConfig::default()->withDecodeFlags(JSON_BIGINT_AS_STRING); + + expect($config->decodeFlags)->toBe(JSON_BIGINT_AS_STRING); + }); + + it('can modify associative setting', function () { + $config = JsonConfig::default()->withAssociative(false); + + expect($config->associative)->toBeFalse(); + }); + + it('throws exception for invalid max depth', function () { + new JsonConfig(maxDepth: 0); + })->throws(\InvalidArgumentException::class, 'Max depth must be at least 1'); + + it('throws exception for negative max depth', function () { + new JsonConfig(maxDepth: -1); + })->throws(\InvalidArgumentException::class); + + it('throws exception when max depth exceeds maximum value', function () { + new JsonConfig(maxDepth: 2147483648); // PHP_INT_MAX + 1 on 32-bit or exceeds validation + })->throws(\InvalidArgumentException::class, 'Max depth exceeds maximum allowed value'); + + it('allows chaining configuration methods', function () { + $config = JsonConfig::default() + ->withMaxDepth(256) + ->withAssociative(false) + ->withEncodeFlags(JSON_PRETTY_PRINT); + + expect($config->maxDepth)->toBe(256) + ->and($config->associative)->toBeFalse() + ->and($config->encodeFlags)->toBe(JSON_PRETTY_PRINT); + }); +}); diff --git a/tests/Serialization/JsonSerializerTest.php b/tests/Serialization/JsonSerializerTest.php new file mode 100644 index 0000000..df2e193 --- /dev/null +++ b/tests/Serialization/JsonSerializerTest.php @@ -0,0 +1,247 @@ + 'John', 'age' => 30]; + + $result = $serializer->encode($data); + + expect($result)->toBeString() + ->and(json_decode($result, true))->toBe($data); + }); + + it('decodes JSON string to array', function () { + $serializer = new JsonSerializer; + $json = '{"name":"John","age":30}'; + + $result = $serializer->decode($json); + + expect($result)->toBe(['name' => 'John', 'age' => 30]); + }); + + it('decodes nested JSON using dot notation', function () { + $serializer = new JsonSerializer; + $json = '{"user":{"name":"John","address":{"city":"NYC"}}}'; + + expect($serializer->decode($json, 'user.name'))->toBe('John') + ->and($serializer->decode($json, 'user.address.city'))->toBe('NYC'); + }); + + it('returns null for empty string', function () { + $serializer = new JsonSerializer; + + expect($serializer->decode(''))->toBeNull(); + }); + + it('throws JsonParseException on invalid JSON', function () { + $serializer = new JsonSerializer; + + $serializer->decode('{"invalid": json}'); + })->throws(JsonParseException::class); + + it('decodeOrNull returns null on invalid JSON', function () { + $serializer = new JsonSerializer; + + expect($serializer->decodeOrNull('{"invalid": json}'))->toBeNull(); + }); + + it('handles large integers with BIGINT_AS_STRING', function () { + $serializer = new JsonSerializer; + // Use a number larger than PHP_INT_MAX to ensure it's converted to string + $json = '{"bigNumber": 99999999999999999999}'; + + $result = $serializer->decode($json); + + // Should be converted to string to prevent overflow + expect($result['bigNumber'])->toBeString() + ->and($result['bigNumber'])->toBe('99999999999999999999'); + }); + + it('uses unescaped slashes by default', function () { + $serializer = new JsonSerializer; + $data = ['url' => 'https://example.com/path']; + + $result = $serializer->encode($data); + + expect($result)->toContain('https://example.com/path') + ->and($result)->not->toContain('\\/'); + }); + + it('uses unescaped unicode by default', function () { + $serializer = new JsonSerializer; + $data = ['text' => 'Hello 世界']; + + $result = $serializer->encode($data); + + expect($result)->toContain('世界'); + }); + + it('respects max depth configuration', function () { + $config = JsonConfig::default()->withMaxDepth(2); + $serializer = new JsonSerializer($config); + + // Create deeply nested data (depth > 2) + $data = ['level1' => ['level2' => ['level3' => 'value']]]; + + $serializer->encode($data); + })->throws(JsonEncodeException::class); + + it('can use pretty print configuration', function () { + $config = JsonConfig::prettyPrint(); + $serializer = new JsonSerializer($config); + $data = ['name' => 'John', 'age' => 30]; + + $result = $serializer->encode($data); + + expect($result)->toContain("\n") + ->and($result)->toContain(' '); // Indentation + }); + + it('returns correct content type', function () { + $serializer = new JsonSerializer; + + expect($serializer->getContentType())->toBe('application/json'); + }); + + it('exposes configuration', function () { + $config = JsonConfig::strict(); + $serializer = new JsonSerializer($config); + + expect($serializer->getConfig())->toBe($config); + }); + + it('handles array conversion to associative array by default', function () { + $serializer = new JsonSerializer; + $json = '["a","b","c"]'; + + $result = $serializer->decode($json); + + expect($result)->toBe(['a', 'b', 'c']) + ->and($result)->toBeArray(); + }); + + it('can decode as object when configured', function () { + $config = JsonConfig::default()->withAssociative(false); + $serializer = new JsonSerializer($config); + $json = '{"name":"John"}'; + + $result = $serializer->decode($json); + + expect($result)->toBeObject() + ->and($result->name)->toBe('John'); + }); + + it('provides detailed error information in exceptions', function () { + $serializer = new JsonSerializer; + $invalidJson = '{"invalid": }'; + + try { + $serializer->decode($invalidJson); + expect(true)->toBeFalse(); // Should not reach here + } catch (JsonParseException $e) { + expect($e->jsonString)->toBe($invalidJson) + ->and($e->jsonErrorCode)->toBeInt() + ->and($e->jsonErrorMessage)->toBeString() + ->and($e->depth)->toBe(512) + ->and($e->format)->toBe('JSON'); + } + }); + + it('handles encoding errors gracefully', function () { + $serializer = new JsonSerializer; + + // Create a resource that cannot be JSON encoded + $resource = fopen('php://memory', 'r'); + + try { + $serializer->encode(['resource' => $resource]); + expect(true)->toBeFalse(); // Should not reach here + } catch (JsonEncodeException $e) { + expect($e->jsonErrorCode)->toBeInt() + ->and($e->jsonErrorMessage)->toBeString() + ->and($e->format)->toBe('JSON'); + } finally { + fclose($resource); + } + }); + + it('handles null values correctly', function () { + $serializer = new JsonSerializer; + $data = ['value' => null]; + + $encoded = $serializer->encode($data); + $decoded = $serializer->decode($encoded); + + expect($decoded)->toBe($data); + }); + + it('handles empty arrays correctly', function () { + $serializer = new JsonSerializer; + $data = []; + + $encoded = $serializer->encode($data); + $decoded = $serializer->decode($encoded); + + expect($decoded)->toBe($data); + }); + + it('extracts nested array values with dot notation', function () { + $serializer = new JsonSerializer; + $json = '{"items":[{"id":1,"name":"First"},{"id":2,"name":"Second"}]}'; + + expect($serializer->decode($json, 'items.0.name'))->toBe('First') + ->and($serializer->decode($json, 'items.1.id'))->toBe(2); + }); + + it('can extract values from object data with dot notation', function () { + $config = JsonConfig::default()->withAssociative(false); + $serializer = new JsonSerializer($config); + $json = '{"user":{"name":"John","email":"john@example.com"}}'; + + $result = $serializer->decode($json, 'user.name'); + + expect($result)->toBe('John'); + }); + + it('returns scalar value when key is empty string', function () { + $serializer = new JsonSerializer; + $json = '"just a string"'; + + $result = $serializer->decode($json, ''); + + expect($result)->toBe('just a string'); + }); + + it('returns null for scalar value with non-empty key', function () { + $serializer = new JsonSerializer; + $json = '"just a string"'; + + $result = $serializer->decode($json, 'some.key'); + + expect($result)->toBeNull(); + }); + + it('handles integer scalar with key extraction', function () { + $serializer = new JsonSerializer; + $json = '42'; + + expect($serializer->decode($json, ''))->toBe(42) + ->and($serializer->decode($json, 'key'))->toBeNull(); + }); + + it('handles boolean scalar with key extraction', function () { + $serializer = new JsonSerializer; + $json = 'true'; + + expect($serializer->decode($json, ''))->toBeTrue() + ->and($serializer->decode($json, 'key'))->toBeNull(); + }); +}); diff --git a/tests/Serialization/SerializerFactoryTest.php b/tests/Serialization/SerializerFactoryTest.php new file mode 100644 index 0000000..f49e736 --- /dev/null +++ b/tests/Serialization/SerializerFactoryTest.php @@ -0,0 +1,142 @@ +toBeInstanceOf(JsonSerializer::class) + ->and($serializer)->toBeInstanceOf(SerializerInterface::class); + }); + + it('creates strict JSON serializer', function () { + $serializer = SerializerFactory::createStrict(); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class) + ->and($serializer->getConfig()->maxDepth)->toBe(128); + }); + + it('creates lenient JSON serializer', function () { + $serializer = SerializerFactory::createLenient(); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class) + ->and($serializer->getConfig()->maxDepth)->toBe(1024); + }); + + it('creates pretty-print JSON serializer', function () { + $serializer = SerializerFactory::createPrettyPrint(); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class) + ->and($serializer->getConfig()->encodeFlags & JSON_PRETTY_PRINT)->toBeGreaterThan(0); + }); + + it('creates serializer with custom config', function () { + $config = JsonConfig::default()->withMaxDepth(256); + $serializer = SerializerFactory::createWithConfig($config); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class) + ->and($serializer->getConfig())->toBe($config); + }); + + it('creates serializer from application/json content type', function () { + $serializer = SerializerFactory::createFromContentType('application/json'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); + + it('creates serializer from text/json content type', function () { + $serializer = SerializerFactory::createFromContentType('text/json'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); + + it('creates serializer from content type with charset', function () { + $serializer = SerializerFactory::createFromContentType('application/json; charset=utf-8'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); + + it('normalizes content type case', function () { + $serializer = SerializerFactory::createFromContentType('APPLICATION/JSON'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); + + it('throws exception for unsupported content type', function () { + SerializerFactory::createFromContentType('application/xml'); + })->throws(\InvalidArgumentException::class, 'Unsupported content type'); + + it('can register custom serializer', function () { + $customSerializer = Mockery::mock(SerializerInterface::class); + SerializerFactory::registerCustomSerializer('application/custom', $customSerializer); + + $result = SerializerFactory::createFromContentType('application/custom'); + + expect($result)->toBe($customSerializer); + }); + + it('custom serializer overrides built-in', function () { + $customSerializer = Mockery::mock(SerializerInterface::class); + SerializerFactory::registerCustomSerializer('application/json', $customSerializer); + + $result = SerializerFactory::createFromContentType('application/json'); + + expect($result)->toBe($customSerializer); + }); + + it('checks if content type is supported', function () { + expect(SerializerFactory::supports('application/json'))->toBeTrue() + ->and(SerializerFactory::supports('text/json'))->toBeTrue() + ->and(SerializerFactory::supports('application/xml'))->toBeFalse(); + }); + + it('supports custom content types', function () { + $customSerializer = Mockery::mock(SerializerInterface::class); + SerializerFactory::registerCustomSerializer('application/custom', $customSerializer); + + expect(SerializerFactory::supports('application/custom'))->toBeTrue(); + }); + + it('supports content type with charset', function () { + expect(SerializerFactory::supports('application/json; charset=utf-8'))->toBeTrue(); + }); + + it('can clear custom serializers', function () { + $customSerializer = Mockery::mock(SerializerInterface::class); + SerializerFactory::registerCustomSerializer('application/custom', $customSerializer); + + expect(SerializerFactory::supports('application/custom'))->toBeTrue(); + + SerializerFactory::clearCustomSerializers(); + + expect(SerializerFactory::supports('application/custom'))->toBeFalse(); + }); + + it('supports application/javascript content type', function () { + $serializer = SerializerFactory::createFromContentType('application/javascript'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); + + it('supports application/x-javascript content type', function () { + $serializer = SerializerFactory::createFromContentType('application/x-javascript'); + + expect($serializer)->toBeInstanceOf(JsonSerializer::class); + }); +}); + +afterEach(function () { + Mockery::close(); + SerializerFactory::clearCustomSerializers(); +}); diff --git a/tests/TransportConfigTest.php b/tests/TransportConfigTest.php new file mode 100644 index 0000000..693c373 --- /dev/null +++ b/tests/TransportConfigTest.php @@ -0,0 +1,256 @@ +client = Mockery::mock(ClientInterface::class); + }); + + it('can be created with default values', function () { + $config = new TransportConfig($this->client); + + expect($config->client)->toBe($this->client) + ->and($config->logger)->toBeInstanceOf(NullLogger::class) + ->and($config->baseUri)->toBe('') + ->and($config->headers)->toBe([]) + ->and($config->timeout)->toBe(30) + ->and($config->maxRetries)->toBe(0) + ->and($config->retryStrategy)->toBeInstanceOf(ExponentialBackoffStrategy::class) + ->and($config->retryCondition)->toBeInstanceOf(RetryCondition::class) + ->and($config->middlewares)->toBe([]); + }); + + it('can be created with custom values', function () { + $logger = Mockery::mock(LoggerInterface::class); + $retryStrategy = new FixedDelayStrategy(1000); + $retryCondition = new RetryCondition; + $middleware = Mockery::mock(MiddlewareInterface::class); + + $config = new TransportConfig( + client: $this->client, + logger: $logger, + baseUri: 'https://api.example.com', + headers: ['Authorization' => 'Bearer token'], + timeout: 60, + maxRetries: 3, + retryStrategy: $retryStrategy, + retryCondition: $retryCondition, + middlewares: [$middleware] + ); + + expect($config->client)->toBe($this->client) + ->and($config->logger)->toBe($logger) + ->and($config->baseUri)->toBe('https://api.example.com') + ->and($config->headers)->toBe(['Authorization' => 'Bearer token']) + ->and($config->timeout)->toBe(60) + ->and($config->maxRetries)->toBe(3) + ->and($config->retryStrategy)->toBe($retryStrategy) + ->and($config->retryCondition)->toBe($retryCondition) + ->and($config->middlewares)->toHaveCount(1); + }); + + it('throws exception for negative timeout', function () { + expect(fn () => new TransportConfig($this->client, timeout: -1)) + ->toThrow(InvalidArgumentException::class, 'Timeout must be greater than or equal to 0.'); + }); + + it('throws exception for negative max retries', function () { + expect(fn () => new TransportConfig($this->client, maxRetries: -5)) + ->toThrow(InvalidArgumentException::class, 'Max retries must be greater than or equal to 0.'); + }); + + it('throws exception for invalid middleware', function () { + $invalidMiddleware = new stdClass; + + expect(fn () => new TransportConfig($this->client, middlewares: [$invalidMiddleware])) + ->toThrow(InvalidArgumentException::class); + }); + + it('allows zero timeout', function () { + $config = new TransportConfig($this->client, timeout: 0); + + expect($config->timeout)->toBe(0); + }); + + it('allows zero max retries', function () { + $config = new TransportConfig($this->client, maxRetries: 0); + + expect($config->maxRetries)->toBe(0); + }); + + it('withHeaders creates new instance with merged headers', function () { + $config = new TransportConfig($this->client, headers: ['Accept' => 'application/json']); + $newConfig = $config->withHeaders(['Authorization' => 'Bearer token']); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->headers)->toBe([ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer token', + ]) + ->and($config->headers)->toBe(['Accept' => 'application/json']); + }); + + it('withHeaders overwrites existing headers', function () { + $config = new TransportConfig($this->client, headers: ['Accept' => 'application/json']); + $newConfig = $config->withHeaders(['Accept' => 'text/html']); + + expect($newConfig->headers)->toBe(['Accept' => 'text/html']); + }); + + it('withBaseUri creates new instance with updated base URI', function () { + $config = new TransportConfig($this->client, baseUri: 'https://api.example.com'); + $newConfig = $config->withBaseUri('https://api.newdomain.com'); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->baseUri)->toBe('https://api.newdomain.com') + ->and($config->baseUri)->toBe('https://api.example.com'); + }); + + it('withTimeout creates new instance with updated timeout', function () { + $config = new TransportConfig($this->client, timeout: 30); + $newConfig = $config->withTimeout(60); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->timeout)->toBe(60) + ->and($config->timeout)->toBe(30); + }); + + it('withTimeout validates timeout value', function () { + $config = new TransportConfig($this->client); + + expect(fn () => $config->withTimeout(-1)) + ->toThrow(InvalidArgumentException::class, 'Timeout must be greater than or equal to 0.'); + }); + + it('withRetries creates new instance with updated retry settings', function () { + $config = new TransportConfig($this->client, maxRetries: 0); + $newStrategy = new FixedDelayStrategy(2000); + $newCondition = new RetryCondition; + + $newConfig = $config->withRetries(5, $newStrategy, $newCondition); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->maxRetries)->toBe(5) + ->and($newConfig->retryStrategy)->toBe($newStrategy) + ->and($newConfig->retryCondition)->toBe($newCondition) + ->and($config->maxRetries)->toBe(0); + }); + + it('withRetries can update max retries only', function () { + $originalStrategy = new FixedDelayStrategy(1000); + $originalCondition = new RetryCondition; + $config = new TransportConfig( + $this->client, + maxRetries: 3, + retryStrategy: $originalStrategy, + retryCondition: $originalCondition + ); + + $newConfig = $config->withRetries(10); + + expect($newConfig->maxRetries)->toBe(10) + ->and($newConfig->retryStrategy)->toBe($originalStrategy) + ->and($newConfig->retryCondition)->toBe($originalCondition); + }); + + it('withRetries validates max retries value', function () { + $config = new TransportConfig($this->client); + + expect(fn () => $config->withRetries(-1)) + ->toThrow(InvalidArgumentException::class, 'Max retries must be greater than or equal to 0.'); + }); + + it('withMiddleware creates new instance with added middleware', function () { + $middleware1 = Mockery::mock(MiddlewareInterface::class); + $middleware2 = Mockery::mock(MiddlewareInterface::class); + + $config = new TransportConfig($this->client, middlewares: [$middleware1]); + $newConfig = $config->withMiddleware($middleware2); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->middlewares)->toHaveCount(2) + ->and($newConfig->middlewares[0])->toBe($middleware1) + ->and($newConfig->middlewares[1])->toBe($middleware2) + ->and($config->middlewares)->toHaveCount(1); + }); + + it('withMiddleware preserves middleware order', function () { + $middleware1 = Mockery::mock(MiddlewareInterface::class); + $middleware2 = Mockery::mock(MiddlewareInterface::class); + $middleware3 = Mockery::mock(MiddlewareInterface::class); + + $config = new TransportConfig($this->client); + $config = $config->withMiddleware($middleware1); + $config = $config->withMiddleware($middleware2); + $config = $config->withMiddleware($middleware3); + + expect($config->middlewares)->toHaveCount(3) + ->and($config->middlewares[0])->toBe($middleware1) + ->and($config->middlewares[1])->toBe($middleware2) + ->and($config->middlewares[2])->toBe($middleware3); + }); + + it('withLogger creates new instance with updated logger', function () { + $logger1 = Mockery::mock(LoggerInterface::class); + $logger2 = Mockery::mock(LoggerInterface::class); + + $config = new TransportConfig($this->client, logger: $logger1); + $newConfig = $config->withLogger($logger2); + + expect($newConfig)->not->toBe($config) + ->and($newConfig->logger)->toBe($logger2) + ->and($config->logger)->toBe($logger1); + }); + + it('is immutable when using with methods', function () { + $config = new TransportConfig( + client: $this->client, + baseUri: 'https://api.example.com', + headers: ['Accept' => 'application/json'], + timeout: 30, + maxRetries: 3 + ); + + $config->withHeaders(['Authorization' => 'Bearer token']); + $config->withBaseUri('https://new.example.com'); + $config->withTimeout(60); + $config->withRetries(5); + + // Original config should remain unchanged + expect($config->headers)->toBe(['Accept' => 'application/json']) + ->and($config->baseUri)->toBe('https://api.example.com') + ->and($config->timeout)->toBe(30) + ->and($config->maxRetries)->toBe(3); + }); + + it('can chain multiple with methods', function () { + $middleware = Mockery::mock(MiddlewareInterface::class); + $logger = Mockery::mock(LoggerInterface::class); + + $config = new TransportConfig($this->client); + $newConfig = $config + ->withHeaders(['Accept' => 'application/json']) + ->withBaseUri('https://api.example.com') + ->withTimeout(60) + ->withRetries(3) + ->withMiddleware($middleware) + ->withLogger($logger); + + expect($newConfig->headers)->toBe(['Accept' => 'application/json']) + ->and($newConfig->baseUri)->toBe('https://api.example.com') + ->and($newConfig->timeout)->toBe(60) + ->and($newConfig->maxRetries)->toBe(3) + ->and($newConfig->middlewares)->toHaveCount(1) + ->and($newConfig->logger)->toBe($logger); + }); +}); diff --git a/tests/TransportTest.php b/tests/TransportTest.php index 449265f..8b26fe8 100644 --- a/tests/TransportTest.php +++ b/tests/TransportTest.php @@ -1,170 +1,346 @@ build(); - - expect($transport)->toBeInstanceOf(Transport::class); - expect($transport->getPsrClient())->toBeInstanceOf(GuzzleClient::class); - expect($transport->getLogger())->toBeInstanceOf(NullLogger::class); +describe('TransportBuilder', function () { + it('can build transport with default values', function () { + $transport = TransportBuilder::make()->build(); - expect($transport->getUri())->toBe('/'); + expect($transport)->toBeInstanceOf(Transport::class) + ->and($transport->getPsrClient())->toBeInstanceOf(\Psr\Http\Client\ClientInterface::class) // Auto-detected client + ->and($transport->getLogger())->toBeInstanceOf(NullLogger::class) + ->and($transport->getUri())->toBe('') + ->and($transport->getTimeout())->toBe(30) + ->and($transport->getRetries())->toBe(0); + }); + + it('can build transport with custom client and logger', function () { + $client = new GuzzleClient; + $logger = new NullLogger; + + $transport = TransportBuilder::make() + ->setClient($client) + ->setLogger($logger) + ->build(); + + expect($transport->getPsrClient())->toBe($client) + ->and($transport->getLogger())->toBe($logger); + }); + + it('can configure base URI', function () { + $transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->build(); + + expect($transport->getUri())->toBe('https://api.example.com'); + }); + + it('can configure headers', function () { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Custom' => 'value', + ]; + + $transport = TransportBuilder::make() + ->withHeaders($headers) + ->build(); + + expect($transport->getHeaders())->toBe($headers); + }); + + it('can configure timeout', function () { + $transport = TransportBuilder::make() + ->withTimeout(60) + ->build(); + + expect($transport->getTimeout())->toBe(60); + }); + + it('can configure retries', function () { + $transport = TransportBuilder::make() + ->withRetries(5) + ->build(); + + expect($transport->getRetries())->toBe(5); + }); + + it('builder is immutable', function () { + $builder = TransportBuilder::make(); + $builder2 = $builder->withTimeout(60); + + expect($builder)->not->toBe($builder2); + }); + + it('can get configured client', function () { + $client = new GuzzleClient; + $builder = TransportBuilder::make()->setClient($client); + + expect($builder->getClient())->toBe($client); + }); + + it('can get configured logger', function () { + $logger = new NullLogger; + $builder = TransportBuilder::make()->setLogger($logger); + + expect($builder->getLogger())->toBe($logger); + }); + + it('returns null when no client configured', function () { + $builder = TransportBuilder::make(); + + expect($builder->getClient())->toBeNull(); + }); + + it('returns null when no logger configured', function () { + $builder = TransportBuilder::make(); + + expect($builder->getLogger())->toBeNull(); + }); + + it('can add custom middleware', function () { + $middleware = Mockery::mock(\Farzai\Transport\Middleware\MiddlewareInterface::class); + $middleware->shouldReceive('handle') + ->once() + ->andReturnUsing(function ($request, $next) { + return $next($request); + }); + + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->andReturn(new GuzzleResponse(200, [], '{}')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withMiddleware($middleware) + ->withoutDefaultMiddlewares() + ->build(); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com'); + $transport->sendRequest($request); + }); }); -it('can build transport with builder', function () { - $builder = TransportBuilder::make() - ->setClient(new GuzzleClient()) - ->setLogger(new NullLogger()); - - expect($builder->getClient())->toBeInstanceOf(GuzzleClient::class); - expect($builder->getLogger())->toBeInstanceOf(NullLogger::class); - - $transport = $builder->build(); - - expect($transport)->toBeInstanceOf(Transport::class); - expect($transport->getPsrClient())->toBeInstanceOf(GuzzleClient::class); - expect($transport->getLogger())->toBeInstanceOf(NullLogger::class); - - expect($transport->getUri())->toBe('/'); +describe('Transport', function () { + it('can send PSR request and get PSR response', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->andReturn(new GuzzleResponse(200, [], 'Hello World')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/api/test'); + $response = $transport->sendRequest($request); + + expect($response->getStatusCode())->toBe(200) + ->and($response->getBody()->getContents())->toBe('Hello World'); + }); + + it('prepends base URI to relative requests', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + return (string) $request->getUri() === 'https://api.example.com/users/123'; + })) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withBaseUri('https://api.example.com') + ->withoutDefaultMiddlewares() + ->build(); + + $request = new \GuzzleHttp\Psr7\Request('GET', '/users/123'); + $transport->sendRequest($request); + }); + + it('adds default headers to requests', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + return $request->getHeaderLine('X-Custom') === 'value' + && $request->getHeaderLine('Accept') === 'application/json'; + })) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withHeaders([ + 'X-Custom' => 'value', + 'Accept' => 'application/json', + ]) + ->withoutDefaultMiddlewares() + ->build(); + + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com'); + $transport->sendRequest($request); + }); + + it('does not override request headers with default headers', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + return $request->getHeaderLine('Content-Type') === 'text/plain'; + })) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withHeaders(['Content-Type' => 'application/json']) + ->withoutDefaultMiddlewares() + ->build(); + + $request = new \GuzzleHttp\Psr7\Request( + 'POST', + 'https://example.com', + ['Content-Type' => 'text/plain'] + ); + $transport->sendRequest($request); + }); + + it('can get transport config', function () { + $transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->withTimeout(60) + ->withRetries(3) + ->build(); + + $config = $transport->getConfig(); + + expect($config)->toBeInstanceOf(\Farzai\Transport\TransportConfig::class) + ->and($config->baseUri)->toBe('https://api.example.com') + ->and($config->timeout)->toBe(60) + ->and($config->maxRetries)->toBe(3); + }); + + it('send method returns ResponseInterface when PSR response is already ResponseInterface', function () { + $client = Mockery::mock(ClientInterface::class); + $request = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com'); + + // Create a Response that implements both PSR ResponseInterface and our ResponseInterface + $mockResponse = Mockery::mock(\Farzai\Transport\Contracts\ResponseInterface::class); + $mockResponse->shouldReceive('getStatusCode')->andReturn(200); + $mockResponse->shouldReceive('getReasonPhrase')->andReturn('OK'); + $mockResponse->shouldReceive('getProtocolVersion')->andReturn('1.1'); + $mockResponse->shouldReceive('getHeaders')->andReturn([]); + $mockResponse->shouldReceive('hasHeader')->andReturn(false); + $mockResponse->shouldReceive('getHeader')->andReturn([]); + $mockResponse->shouldReceive('getHeaderLine')->andReturn(''); + $mockResponse->shouldReceive('getBody')->andReturn(Mockery::mock(\Psr\Http\Message\StreamInterface::class)); + + $client->shouldReceive('sendRequest') + ->once() + ->andReturn($mockResponse); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport->send($request); + + expect($response)->toBe($mockResponse); + }); }); -it('should throw error if set timeout less than 0', function () { - $transport = TransportBuilder::make() - ->setClient(new GuzzleClient()) - ->setLogger(new NullLogger()) - ->build(); - - $transport->setTimeout(-1); -})->throws(InvalidArgumentException::class); - -it('should set timeout success', function () { - $transport = TransportBuilder::make() - ->setClient(new GuzzleClient()) - ->setLogger(new NullLogger()) - ->build(); - - $transport->setTimeout(10); - - expect($transport->getTimeout())->toBe(10); +describe('Transport fluent API', function () { + it('can create GET request', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(fn ($req) => $req->getMethod() === 'GET')) + ->andReturn(new GuzzleResponse(200, [], '{"success":true}')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport->get('/users')->send(); + + expect($response->isSuccessful())->toBeTrue(); + }); + + it('can create POST request', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(fn ($req) => $req->getMethod() === 'POST')) + ->andReturn(new GuzzleResponse(201, [], '{"id":123}')); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport->post('/users')->send(); + + expect($response->statusCode())->toBe(201); + }); + + it('can create PUT request', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(fn ($req) => $req->getMethod() === 'PUT')) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $transport->put('/users/123')->send(); + expect(true)->toBeTrue(); + }); + + it('can create PATCH request', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(fn ($req) => $req->getMethod() === 'PATCH')) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $transport->patch('/users/123')->send(); + expect(true)->toBeTrue(); + }); + + it('can create DELETE request', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(fn ($req) => $req->getMethod() === 'DELETE')) + ->andReturn(new GuzzleResponse(204)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withoutDefaultMiddlewares() + ->build(); + + $response = $transport->delete('/users/123')->send(); + expect($response->statusCode())->toBe(204); + }); }); -it('can send a request', function () { - $client = $this->createMock(ClientInterface::class); - $client - ->expects($this->once()) - ->method('sendRequest') - ->willReturn(new Response(200, [], 'Hello World')); - - $transport = TransportBuilder::make()->setClient($client)->build(); - - $request = new Request('GET', new Uri('https://example.com/api/v1/test')); - - $response = $transport->sendRequest($request); - - expect($response)->toBeInstanceOf(Response::class); - - expect($response->getStatusCode())->toBe(200); - expect($response->getBody()->getContents())->toBe('Hello World'); -}); - -it('can get the base URI', function () { - $client = new GuzzleClient(); - - $transport = TransportBuilder::make()->setClient($client)->build(); - - expect($transport->getUri())->toBe('/'); -}); - -it('can set the base URI', function () { - $client = new GuzzleClient(); - - $transport = TransportBuilder::make()->setClient($client)->build(); - - $transport->setUri('https://example.com'); - - expect($transport->getUri())->toBe('https://example.com'); -}); - -it('can get the headers', function () { - $transport = TransportBuilder::make()->build(); - - expect($transport->getHeaders())->toBe([]); -}); - -it('can set the headers', function () { - $transport = TransportBuilder::make()->build(); - - $transport->setHeaders([ - 'Content-Type' => 'application/json', - 'X-Tester' => 'Farzai', - ]); - - expect($transport->getHeaders())->toBe([ - 'Content-Type' => 'application/json', - 'X-Tester' => 'Farzai', - ]); -}); - -it('can get the retries', function () { - $transport = TransportBuilder::make()->build(); - - expect($transport->getRetries())->toBe(0); -}); - -it('can set the retries', function () { - $transport = TransportBuilder::make()->build(); - - $transport->setRetries(3); - - expect($transport->getRetries())->toBe(3); -}); - -it('should throw an exception when retries is less than 0', function () { - $transport = TransportBuilder::make()->build(); - - $transport->setRetries(-1); -})->throws(InvalidArgumentException::class); - -it('can get valid request options', function () { - $transport = TransportBuilder::make()->build(); - - $transport->setHeaders( - $expectHeaders = [ - 'Content-Type' => ['application/json'], - 'X-Tester' => ['Farzai'], - ] - ); - - $transport->setUri('https://example.com'); - - $request = new Request('POST', new Uri('/api/v1/test?foo=bar')); - $resultRequest = $transport->setupRequest($request); - - expect('https://example.com/api/v1/test?foo=bar')->toBe( - $resultRequest->getUri()->__toString() - ); - expect('POST')->toBe($resultRequest->getMethod()); - expect('https')->toBe($resultRequest->getUri()->getScheme()); - - // Headers - expect('application/json')->toBe( - $resultRequest->getHeaderLine('Content-Type') - ); - expect('Farzai')->toBe($resultRequest->getHeaderLine('X-Tester')); - - // Expect headers - expect(['Host' => ['example.com']] + $expectHeaders)->toBe( - $resultRequest->getHeaders() - ); - - // Query - expect('foo=bar')->toBe($resultRequest->getUri()->getQuery()); +afterEach(function () { + Mockery::close(); });