diff --git a/README.md b/README.md index 0edd936..49c0db7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ A modern, PSR-compliant HTTP client for PHP with middleware architecture, advanc - ✅ **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 +- ✅ **File Upload & Multipart** - RFC 7578 compliant multipart/form-data with file upload support +- ✅ **Cookie Management** - RFC 6265 compliant automatic cookie handling with session support ## Requirements @@ -242,6 +244,107 @@ $transport = TransportBuilder::make() echo ClientFactory::getDetectedClientName(); // e.g., "Symfony\Component\HttpClient\Psr18Client" ``` +### File Upload & Multipart Requests + +```php +// Simple file upload +$response = $transport->post('/upload') + ->withFile( + name: 'document', + path: '/path/to/file.pdf', + filename: 'report.pdf', + additionalFields: ['title' => 'Monthly Report'] + ) + ->send(); + +// Multiple files with form data +$response = $transport->post('/upload') + ->withMultipart([ + // Text fields + ['name' => 'title', 'contents' => 'My Upload'], + ['name' => 'description', 'contents' => 'File description'], + + // File uploads + [ + 'name' => 'avatar', + 'contents' => file_get_contents('photo.jpg'), + 'filename' => 'avatar.jpg', + 'content-type' => 'image/jpeg' + ], + [ + 'name' => 'document', + 'contents' => fopen('/path/to/file.pdf', 'r'), + 'filename' => 'document.pdf' + ] + ]) + ->send(); + +// Advanced: Using MultipartStreamBuilder +use Farzai\Transport\Multipart\MultipartStreamBuilder; + +$builder = new MultipartStreamBuilder(); +$builder->addField('username', 'john_doe') + ->addFile('avatar', '/path/to/avatar.jpg', 'profile.jpg') + ->addFileContents('data', $jsonData, 'data.json', 'application/json'); + +$response = $transport->post('/api/upload') + ->withMultipartBuilder($builder) + ->send(); +``` + +### Cookie Management + +```php +// Automatic cookie handling +$transport = TransportBuilder::make() + ->withBaseUri('https://api.example.com') + ->withCookies() // Enable automatic cookie management + ->build(); + +// Login - cookies are automatically stored +$transport->post('/login') + ->withJson(['username' => 'user', 'password' => 'pass']) + ->send(); + +// Subsequent requests automatically include cookies +$response = $transport->get('/profile')->send(); + +// Advanced: Manual cookie management +use Farzai\Transport\Cookie\CookieJar; +use Farzai\Transport\Cookie\Cookie; + +$cookieJar = new CookieJar(); + +// Add cookies manually +$cookieJar->setCookie(new Cookie( + name: 'session_id', + value: 'abc123', + expiresAt: time() + 3600, + domain: 'example.com', + path: '/', + secure: true, + httpOnly: true +)); + +$transport = TransportBuilder::make() + ->withCookieJar($cookieJar) + ->build(); + +// Inspect cookies +echo "Cookies: {$cookieJar->count()}\n"; +foreach ($cookieJar->getAllCookies() as $cookie) { + echo "{$cookie->getName()}: {$cookie->getValue()}\n"; +} + +// Export/Import cookies for persistence +$data = $cookieJar->toArray(); +file_put_contents('cookies.json', json_encode($data)); + +// Later... +$newJar = new CookieJar(); +$newJar->fromArray(json_decode(file_get_contents('cookies.json'), true)); +``` + ### Error Handling ```php @@ -346,6 +449,8 @@ $response = ResponseBuilder::create() - [Custom HTTP Clients](examples/custom-client.php) - [Advanced Retry Logic](examples/advanced-retry.php) - [Custom Middleware](examples/middleware-example.php) + - [File Upload](examples/file-upload.php) + - [Cookie Session Management](examples/cookie-session.php) ## Architecture diff --git a/examples/cookie-session.php b/examples/cookie-session.php new file mode 100644 index 0000000..99bdea8 --- /dev/null +++ b/examples/cookie-session.php @@ -0,0 +1,286 @@ +withBaseUri('https://httpbin.org') + ->withCookies() // Enable automatic cookie management + ->build(); + + // First request - server will set cookies + echo "Making initial request...\n"; + $response1 = $transport->get('/cookies/set') + ->withQuery(['session' => 'abc123', 'user_id' => '42']) + ->send(); + + echo "Status: {$response1->statusCode()}\n"; + echo "Cookies received and stored automatically\n"; + + // Second request - cookies are automatically sent + echo "\nMaking authenticated request...\n"; + $response2 = $transport->get('/cookies')->send(); + + $cookies = $response2->json('cookies'); + echo 'Cookies sent: '.json_encode($cookies, JSON_PRETTY_PRINT)."\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 2: Simulated Login Flow +echo "2. Simulated Login Flow\n"; +echo str_repeat('-', 50)."\n"; + +try { + $cookieJar = new CookieJar; + + $transport = TransportBuilder::make() + ->withBaseUri('https://httpbin.org') + ->withCookieJar($cookieJar) + ->build(); + + // Step 1: Login + echo "Step 1: Logging in...\n"; + $loginResponse = $transport->post('/cookies/set') + ->withQuery([ + 'auth_token' => 'secret_token_xyz', + 'session_id' => 'sess_'.bin2hex(random_bytes(8)), + ]) + ->send(); + + echo "Login successful! Cookies stored.\n"; + echo "Cookies in jar: {$cookieJar->count()}\n"; + + // Step 2: Make authenticated requests + echo "\nStep 2: Accessing protected resource...\n"; + $profileResponse = $transport->get('/cookies')->send(); + + echo "Profile data retrieved with cookies:\n"; + echo json_encode($profileResponse->json('cookies'), JSON_PRETTY_PRINT)."\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 3: Manual Cookie Management +echo "3. Manual Cookie Management\n"; +echo str_repeat('-', 50)."\n"; + +try { + $cookieJar = new CookieJar; + + // Manually create and add cookies + $sessionCookie = new Cookie( + name: 'session_id', + value: 'manual_session_123', + expiresAt: time() + 3600, // 1 hour + domain: 'httpbin.org', + path: '/', + secure: true, + httpOnly: true, + sameSite: 'Lax' + ); + + $userCookie = new Cookie( + name: 'user_id', + value: '999', + expiresAt: time() + 86400, // 1 day + domain: 'httpbin.org' + ); + + $cookieJar->setCookie($sessionCookie); + $cookieJar->setCookie($userCookie); + + echo "Manually added {$cookieJar->count()} cookies\n"; + + // Use the cookie jar + $transport = TransportBuilder::make() + ->withBaseUri('https://httpbin.org') + ->withCookieJar($cookieJar) + ->build(); + + $response = $transport->get('/cookies')->send(); + + echo "Cookies sent to server:\n"; + echo json_encode($response->json('cookies'), JSON_PRETTY_PRINT)."\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 4: Cookie Inspection and Management +echo "4. Cookie Inspection\n"; +echo str_repeat('-', 50)."\n"; + +try { + $cookieJar = new CookieJar; + + // Add various cookies + $cookieJar->setCookie(new Cookie('cookie1', 'value1', null, 'example.com', '/')); + $cookieJar->setCookie(new Cookie('cookie2', 'value2', time() + 3600, 'example.com', '/api')); + $cookieJar->setCookie(new Cookie('secure_cookie', 'secret', time() + 7200, 'example.com', '/', true)); + + echo "Total cookies: {$cookieJar->count()}\n"; + echo 'Is empty: '.($cookieJar->isEmpty() ? 'Yes' : 'No')."\n\n"; + + // Get all cookies + echo "All cookies:\n"; + foreach ($cookieJar->getAllCookies() as $cookie) { + echo " - {$cookie->getName()}: {$cookie->getValue()}"; + echo " (Domain: {$cookie->getDomain()}, Path: {$cookie->getPath()}"; + echo $cookie->isSecure() ? ', Secure' : ''; + echo $cookie->isSessionCookie() ? ', Session' : ', Persistent'; + echo ")\n"; + } + + // Get cookies for specific URL + echo "\nCookies matching 'https://example.com/api':\n"; + $apiCookies = $cookieJar->getCookiesForUrl('https://example.com/api'); + foreach ($apiCookies as $cookie) { + echo " - {$cookie->getName()}: {$cookie->getValue()}\n"; + } + + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 5: Cookie Persistence (Export/Import) +echo "5. Cookie Persistence (Export/Import)\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create jar and add cookies + $jar1 = new CookieJar; + $jar1->setCookie(new Cookie('persistent', 'data', time() + 86400, 'example.com')); + $jar1->setCookie(new Cookie('preferences', 'dark_mode=true', time() + 2592000, 'example.com')); + + // Export cookies + $exported = $jar1->toArray(); + echo 'Exported cookies: '.json_encode($exported, JSON_PRETTY_PRINT)."\n\n"; + + // Save to file (simulated) + $cookieFile = sys_get_temp_dir().'/transport_cookies.json'; + file_put_contents($cookieFile, json_encode($exported)); + echo "Cookies saved to: {$cookieFile}\n"; + + // Later... Import cookies + $jar2 = new CookieJar; + $imported = json_decode(file_get_contents($cookieFile), true); + $jar2->fromArray($imported); + + echo "Imported {$jar2->count()} cookies\n"; + + // Clean up + unlink($cookieFile); + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 6: Cookie Filtering and Removal +echo "6. Cookie Filtering and Removal\n"; +echo str_repeat('-', 50)."\n"; + +try { + $cookieJar = new CookieJar; + + // Add mix of cookies + $cookieJar->setCookie(new Cookie('keep', 'value', time() + 3600)); + $cookieJar->setCookie(new Cookie('remove', 'value', time() + 3600, 'example.com')); + $cookieJar->setCookie(new Cookie('expired', 'value', time() - 3600)); // Already expired + + echo "Initial count: {$cookieJar->count()}\n"; + + // Remove specific cookie + $cookieJar->removeCookie('remove', 'example.com'); + echo "After removing 'remove': {$cookieJar->count()}\n"; + + // Clear all + $cookieJar->clear(); + echo "After clearing all: {$cookieJar->count()}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 7: Web Scraping with Session +echo "7. Web Scraping Simulation\n"; +echo str_repeat('-', 50)."\n"; + +try { + $cookieJar = new CookieJar; + + // Create transport for scraping + $scraper = TransportBuilder::make() + ->withBaseUri('https://httpbin.org') + ->withCookieJar($cookieJar) + ->withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (compatible; Bot/1.0)', + 'Accept' => 'text/html,application/xhtml+xml', + ]) + ->build(); + + echo "Scraping workflow:\n"; + + // 1. Visit homepage (get session) + echo " 1. Visiting homepage...\n"; + $homepage = $scraper->get('/cookies/set') + ->withQuery(['session' => 'scraper_'.time()]) + ->send(); + echo " Session cookies: {$cookieJar->count()}\n"; + + // 2. Navigate to different pages (cookies persist) + echo " 2. Navigating to data page...\n"; + $dataPage = $scraper->get('/cookies')->send(); + echo " Cookies sent automatically\n"; + + // 3. Check collected cookies + echo " 3. Total cookies collected: {$cookieJar->count()}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 8: Session Cookie vs Persistent Cookie +echo "8. Session vs Persistent Cookies\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Without session persistence (default) + $regularJar = new CookieJar; + $regularJar->setCookie(new Cookie('session', 'value')); // Session cookie + $regularJar->setCookie(new Cookie('persistent', 'value', time() + 3600)); // Persistent + + echo "Regular jar (no session persistence):\n"; + echo " Cookies: {$regularJar->count()}\n"; + + // With session persistence + $persistentJar = CookieJar::withSessionPersistence(); + $persistentJar->setCookie(new Cookie('session', 'value')); // Session cookie + $persistentJar->setCookie(new Cookie('persistent', 'value', time() + 3600)); // Persistent + + echo "Persistent jar (with session persistence):\n"; + echo " Cookies: {$persistentJar->count()}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +echo "=== Examples Complete ===\n"; +echo "\nNote: Cookie management is transparent and automatic when enabled.\n"; +echo "Cookies are matched by domain and path, and secure cookies are only\n"; +echo "sent over HTTPS connections as per RFC 6265 specifications.\n"; diff --git a/examples/file-upload.php b/examples/file-upload.php new file mode 100644 index 0000000..f46de48 --- /dev/null +++ b/examples/file-upload.php @@ -0,0 +1,258 @@ +withBaseUri('https://httpbin.org') + ->withTimeout(60) // Longer timeout for file uploads + ->build(); + +echo "=== Transport PHP File Upload Examples ===\n\n"; + +// Example 1: Simple file upload using withFile() +echo "1. Simple File Upload\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create a temporary test file + $tempFile = tempnam(sys_get_temp_dir(), 'upload_'); + file_put_contents($tempFile, 'This is a test file content for upload example.'); + + $response = $transport->post('/post') + ->withFile( + name: 'document', + path: $tempFile, + filename: 'test-document.txt', + additionalFields: [ + 'description' => 'Test upload', + 'category' => 'documents', + ] + ) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Upload successful!\n"; + + // Clean up + unlink($tempFile); + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 2: Multiple file upload with mixed fields +echo "2. Multiple Files with Form Fields\n"; +echo str_repeat('-', 50)."\n"; + +try { + $response = $transport->post('/post') + ->withMultipart([ + // Text fields + ['name' => 'username', 'contents' => 'john_doe'], + ['name' => 'email', 'contents' => 'john@example.com'], + + // File from contents (string) + [ + 'name' => 'avatar', + 'contents' => 'fake-image-data', + 'filename' => 'avatar.jpg', + 'content-type' => 'image/jpeg', + ], + + // Another file + [ + 'name' => 'document', + 'contents' => 'PDF content here', + 'filename' => 'report.pdf', + 'content-type' => 'application/pdf', + ], + ]) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Multiple files uploaded successfully!\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 3: Upload with simple key-value format +echo "3. Simple Format with Text and Files\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create temporary files + $file1 = tempnam(sys_get_temp_dir(), 'doc1_'); + $file2 = tempnam(sys_get_temp_dir(), 'doc2_'); + file_put_contents($file1, 'Content of first document'); + file_put_contents($file2, 'Content of second document'); + + $response = $transport->post('/post') + ->withMultipart([ + // Simple text fields (key => value) + 'title' => 'My Documents', + 'description' => 'Important files', + + // File uploads (array format) + [ + 'name' => 'file1', + 'contents' => file_get_contents($file1), + 'filename' => 'document1.txt', + ], + [ + 'name' => 'file2', + 'contents' => file_get_contents($file2), + 'filename' => 'document2.txt', + ], + ]) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Documents uploaded with metadata!\n"; + + // Clean up + unlink($file1); + unlink($file2); + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 4: Advanced - Using MultipartStreamBuilder directly +echo "4. Advanced: Using MultipartStreamBuilder\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create builder + $builder = new MultipartStreamBuilder; + + // Add various fields + $builder->addField('user_id', '12345') + ->addField('action', 'upload') + ->addFileContents( + name: 'profile_picture', + contents: 'image-binary-data-here', + filename: 'profile.jpg', + contentType: 'image/jpeg' + ); + + // Create temporary file for another upload + $docFile = tempnam(sys_get_temp_dir(), 'contract_'); + file_put_contents($docFile, 'Contract document content'); + + $builder->addFile( + name: 'contract', + path: $docFile, + filename: 'employment-contract.pdf', + contentType: 'application/pdf' + ); + + // Use the builder + $response = $transport->post('/post') + ->withMultipartBuilder($builder) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Builder boundary: {$builder->getBoundary()}\n"; + echo "Total parts: {$builder->count()}\n"; + + // Clean up + unlink($docFile); + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 5: Image upload with proper content type +echo "5. Image Upload with Content Type\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Simulate image data + $imageData = 'FAKE_PNG_DATA_'.bin2hex(random_bytes(10)); + + $response = $transport->post('/post') + ->withMultipart([ + ['name' => 'title', 'contents' => 'My Vacation Photo'], + ['name' => 'tags', 'contents' => 'vacation,beach,2024'], + [ + 'name' => 'photo', + 'contents' => $imageData, + 'filename' => 'vacation.png', + 'content-type' => 'image/png', + ], + ]) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Image uploaded with metadata!\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 6: Custom boundary +echo "6. Custom Boundary\n"; +echo str_repeat('-', 50)."\n"; + +try { + $customBoundary = 'MyCustomBoundary123456789'; + + $response = $transport->post('/post') + ->withMultipart( + data: [ + 'field1' => 'value1', + 'field2' => 'value2', + ], + boundary: $customBoundary + ) + ->send(); + + echo "Status: {$response->statusCode()}\n"; + echo "Upload with custom boundary: {$customBoundary}\n\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +// Example 7: Large file simulation with progress indication +echo "7. Large File Upload\n"; +echo str_repeat('-', 50)."\n"; + +try { + // Create a larger test file + $largeFile = tempnam(sys_get_temp_dir(), 'large_'); + $content = str_repeat('This is a large file content. ', 1000); + file_put_contents($largeFile, $content); + + echo 'Uploading file ('.round(strlen($content) / 1024, 2)." KB)...\n"; + + $startTime = microtime(true); + + $response = $transport->post('/post') + ->withFile('large_file', $largeFile, 'large-document.txt') + ->send(); + + $duration = round(microtime(true) - $startTime, 2); + + echo "Status: {$response->statusCode()}\n"; + echo "Upload completed in {$duration} seconds\n"; + + // Clean up + unlink($largeFile); + echo "\n"; +} catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n\n"; +} + +echo "=== Examples Complete ===\n"; +echo "\nNote: These examples use httpbin.org which echoes back the received data.\n"; +echo "In production, replace the URL with your actual API endpoint.\n"; diff --git a/src/Cookie/Cookie.php b/src/Cookie/Cookie.php new file mode 100644 index 0000000..ef506e9 --- /dev/null +++ b/src/Cookie/Cookie.php @@ -0,0 +1,408 @@ +validateName($name); + $this->validateSameSite($sameSite); + + $this->name = $name; + $this->value = $value; + $this->expiresAt = $expiresAt; + $this->domain = $domain ? strtolower($domain) : null; + $this->path = $path; + $this->secure = $secure; + $this->httpOnly = $httpOnly; + $this->sameSite = $sameSite; + } + + /** + * Get the cookie name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the cookie value. + */ + public function getValue(): string + { + return $this->value; + } + + /** + * Get the expiration timestamp. + */ + public function getExpiresAt(): ?int + { + return $this->expiresAt; + } + + /** + * Get the domain. + */ + public function getDomain(): ?string + { + return $this->domain; + } + + /** + * Get the path. + */ + public function getPath(): string + { + return $this->path; + } + + /** + * Check if secure flag is set. + */ + public function isSecure(): bool + { + return $this->secure; + } + + /** + * Check if HttpOnly flag is set. + */ + public function isHttpOnly(): bool + { + return $this->httpOnly; + } + + /** + * Get the SameSite attribute. + */ + public function getSameSite(): ?string + { + return $this->sameSite; + } + + /** + * Check if the cookie has expired. + */ + public function isExpired(?int $currentTime = null): bool + { + if ($this->expiresAt === null) { + return false; // Session cookie never expires until browser closes + } + + $currentTime = $currentTime ?? time(); + + return $currentTime >= $this->expiresAt; + } + + /** + * Check if this is a session cookie. + */ + public function isSessionCookie(): bool + { + return $this->expiresAt === null; + } + + /** + * Check if the cookie matches a given domain. + * + * Implements domain matching as per RFC 6265 Section 5.1.3. + * + * @param string $requestDomain The domain to check against + */ + public function matchesDomain(string $requestDomain): bool + { + $requestDomain = strtolower($requestDomain); + + // If cookie has no domain, it only matches the exact origin domain + if ($this->domain === null) { + return true; // Let the jar handle origin domain matching + } + + $cookieDomain = $this->domain; + + // Exact match + if ($cookieDomain === $requestDomain) { + return true; + } + + // Domain cookie (starts with dot or not) + if (! str_starts_with($cookieDomain, '.')) { + $cookieDomain = '.'.$cookieDomain; + } + + // Check if request domain ends with cookie domain + if (str_ends_with('.'.$requestDomain, $cookieDomain)) { + return true; + } + + return false; + } + + /** + * Check if the cookie matches a given path. + * + * Implements path matching as per RFC 6265 Section 5.1.4. + * + * @param string $requestPath The path to check against + */ + public function matchesPath(string $requestPath): bool + { + $cookiePath = $this->path; + + // Exact match + if ($cookiePath === $requestPath) { + return true; + } + + // Cookie path must be a prefix of request path + if (! str_starts_with($requestPath, $cookiePath)) { + return false; + } + + // If cookie path ends with '/', it matches + if (str_ends_with($cookiePath, '/')) { + return true; + } + + // Next character in request path must be '/' + return $requestPath[strlen($cookiePath)] === '/'; + } + + /** + * Check if the cookie matches a given URL. + * + * @param string $url The URL to check against + * @param bool $isSecure Whether the request is secure (HTTPS) + */ + public function matchesUrl(string $url, ?bool $isSecure = null): bool + { + $parsed = parse_url($url); + + if ($parsed === false) { + return false; + } + + $domain = $parsed['host'] ?? ''; + $path = $parsed['path'] ?? '/'; + $isSecure = $isSecure ?? (($parsed['scheme'] ?? '') === 'https'); + + // Check secure flag + if ($this->secure && ! $isSecure) { + return false; + } + + // Check domain and path + return $this->matchesDomain($domain) && $this->matchesPath($path); + } + + /** + * Convert cookie to Set-Cookie header value. + */ + public function toSetCookieHeader(): string + { + $header = "{$this->name}={$this->value}"; + + if ($this->expiresAt !== null) { + $header .= '; Expires='.gmdate('D, d M Y H:i:s T', $this->expiresAt); + $maxAge = $this->expiresAt - time(); + if ($maxAge > 0) { + $header .= '; Max-Age='.$maxAge; + } + } + + if ($this->domain !== null) { + $header .= '; Domain='.$this->domain; + } + + if ($this->path !== '/') { + $header .= '; Path='.$this->path; + } + + if ($this->secure) { + $header .= '; Secure'; + } + + if ($this->httpOnly) { + $header .= '; HttpOnly'; + } + + if ($this->sameSite !== null) { + $header .= '; SameSite='.$this->sameSite; + } + + return $header; + } + + /** + * Convert cookie to Cookie header value (for requests). + */ + public function toCookieHeader(): string + { + return "{$this->name}={$this->value}"; + } + + /** + * Create a new cookie with updated value. + */ + public function withValue(string $value): self + { + return new self( + $this->name, + $value, + $this->expiresAt, + $this->domain, + $this->path, + $this->secure, + $this->httpOnly, + $this->sameSite + ); + } + + /** + * Parse a Set-Cookie header into a Cookie instance. + * + * @param string $setCookieHeader The Set-Cookie header value + * @param string $defaultDomain Default domain if not specified + * + * @throws \InvalidArgumentException If header is invalid + */ + public static function fromSetCookieHeader(string $setCookieHeader, string $defaultDomain = ''): self + { + $parts = array_map('trim', explode(';', $setCookieHeader)); + + if (empty($parts[0]) || ! str_contains($parts[0], '=')) { + throw new \InvalidArgumentException('Invalid Set-Cookie header: missing name=value pair'); + } + + // Parse name=value + [$name, $value] = array_pad(explode('=', $parts[0], 2), 2, ''); + + $name = trim($name); + $value = trim($value); + + if ($name === '') { + throw new \InvalidArgumentException('Cookie name cannot be empty'); + } + + // Parse attributes + $expiresAt = null; + $domain = null; + $path = '/'; + $secure = false; + $httpOnly = false; + $sameSite = null; + + foreach (array_slice($parts, 1) as $part) { + if (stripos($part, 'expires=') === 0) { + $dateStr = substr($part, 8); + $timestamp = strtotime($dateStr); + if ($timestamp !== false) { + $expiresAt = $timestamp; + } + } elseif (stripos($part, 'max-age=') === 0) { + $maxAge = (int) substr($part, 8); + $expiresAt = time() + $maxAge; + } elseif (stripos($part, 'domain=') === 0) { + $domain = substr($part, 7); + } elseif (stripos($part, 'path=') === 0) { + $path = substr($part, 5); + } elseif (stripos($part, 'secure') === 0) { + $secure = true; + } elseif (stripos($part, 'httponly') === 0) { + $httpOnly = true; + } elseif (stripos($part, 'samesite=') === 0) { + $sameSite = substr($part, 9); + } + } + + $domain = $domain ?: ($defaultDomain ?: null); + + return new self($name, $value, $expiresAt, $domain, $path, $secure, $httpOnly, $sameSite); + } + + /** + * Validate cookie name. + * + * @throws \InvalidArgumentException + */ + private function validateName(string $name): void + { + if ($name === '') { + throw new \InvalidArgumentException('Cookie name cannot be empty'); + } + + // RFC 6265 cookie-name validation + if (preg_match('/[()<>@,;:\\\\"\/\[\]?={} \t]/', $name)) { + throw new \InvalidArgumentException("Invalid cookie name: {$name}"); + } + } + + /** + * Validate SameSite value. + * + * @throws \InvalidArgumentException + */ + private function validateSameSite(?string $sameSite): void + { + if ($sameSite !== null && ! in_array($sameSite, ['Strict', 'Lax', 'None'], true)) { + throw new \InvalidArgumentException("Invalid SameSite value: {$sameSite}"); + } + } + + /** + * Get unique identifier for this cookie (name + domain + path). + */ + public function getIdentifier(): string + { + return sprintf('%s|%s|%s', $this->name, $this->domain ?? '', $this->path); + } +} diff --git a/src/Cookie/CookieJar.php b/src/Cookie/CookieJar.php new file mode 100644 index 0000000..bc97799 --- /dev/null +++ b/src/Cookie/CookieJar.php @@ -0,0 +1,332 @@ + + */ + private array $cookies = []; + + /** + * Whether to persist session cookies. + */ + private bool $persistSessionCookies = false; + + /** + * Create a new cookie jar. + * + * @param bool $persistSessionCookies Whether to persist session cookies + */ + public function __construct(bool $persistSessionCookies = false) + { + $this->persistSessionCookies = $persistSessionCookies; + } + + /** + * Set a cookie in the jar. + * + * If a cookie with the same name, domain, and path exists, it will be replaced. + * + * @param Cookie $cookie The cookie to set + * @return $this + */ + public function setCookie(Cookie $cookie): self + { + // Don't store expired cookies + if ($cookie->isExpired()) { + $this->removeCookie($cookie->getName(), $cookie->getDomain(), $cookie->getPath()); + + return $this; + } + + // Don't store session cookies if not persisting them + if (! $this->persistSessionCookies && $cookie->isSessionCookie()) { + // Still store in memory for current session + } + + $this->cookies[$cookie->getIdentifier()] = $cookie; + + return $this; + } + + /** + * Get a specific cookie by name, domain, and path. + * + * @param string $name Cookie name + * @param string|null $domain Cookie domain + * @param string $path Cookie path + */ + public function getCookie(string $name, ?string $domain = null, string $path = '/'): ?Cookie + { + $identifier = sprintf('%s|%s|%s', $name, $domain ?? '', $path); + + return $this->cookies[$identifier] ?? null; + } + + /** + * Get all cookies matching a URL. + * + * Returns cookies sorted by path length (most specific first). + * + * @param string $url The URL to match + * @param bool|null $isSecure Whether the request is secure (auto-detect if null) + * @return array + */ + public function getCookiesForUrl(string $url, ?bool $isSecure = null): array + { + $this->removeExpiredCookies(); + + $parsed = parse_url($url); + if ($parsed === false) { + return []; + } + + $domain = $parsed['host'] ?? ''; + $path = $parsed['path'] ?? '/'; + $isSecure = $isSecure ?? (($parsed['scheme'] ?? '') === 'https'); + + $matching = []; + + foreach ($this->cookies as $cookie) { + if ($cookie->matchesUrl($url, $isSecure)) { + $matching[] = $cookie; + } + } + + // Sort by path length (RFC 6265 Section 5.4) + usort($matching, function (Cookie $a, Cookie $b) { + $lengthA = strlen($a->getPath()); + $lengthB = strlen($b->getPath()); + + if ($lengthA === $lengthB) { + // If same length, sort by creation time (older first) + // Since we don't track creation time, maintain insertion order + return 0; + } + + // Longer paths first (more specific) + return $lengthB <=> $lengthA; + }); + + return $matching; + } + + /** + * Get all cookies. + * + * @param bool $includeExpired Whether to include expired cookies + * @return array + */ + public function getAllCookies(bool $includeExpired = false): array + { + if (! $includeExpired) { + $this->removeExpiredCookies(); + } + + return array_values($this->cookies); + } + + /** + * Remove a specific cookie. + * + * @param string $name Cookie name + * @param string|null $domain Cookie domain + * @param string $path Cookie path + * @return $this + */ + public function removeCookie(string $name, ?string $domain = null, string $path = '/'): self + { + $identifier = sprintf('%s|%s|%s', $name, $domain ?? '', $path); + + unset($this->cookies[$identifier]); + + return $this; + } + + /** + * Remove all cookies. + * + * @return $this + */ + public function clear(): self + { + $this->cookies = []; + + return $this; + } + + /** + * Remove expired cookies. + * + * @return $this + */ + public function removeExpiredCookies(): self + { + $this->cookies = array_filter( + $this->cookies, + fn (Cookie $cookie) => ! $cookie->isExpired() + ); + + return $this; + } + + /** + * Count the number of cookies in the jar. + * + * @param bool $includeExpired Whether to include expired cookies + */ + public function count(bool $includeExpired = false): int + { + if (! $includeExpired) { + $this->removeExpiredCookies(); + } + + return count($this->cookies); + } + + /** + * Check if the jar is empty. + */ + public function isEmpty(): bool + { + $this->removeExpiredCookies(); + + return empty($this->cookies); + } + + /** + * Parse Set-Cookie headers and add cookies to the jar. + * + * @param array|string $headers Set-Cookie header value(s) + * @param string $url The URL these cookies came from (for default domain) + * @return $this + */ + public function addFromSetCookieHeaders(array|string $headers, string $url): self + { + $headers = is_array($headers) ? $headers : [$headers]; + + $parsed = parse_url($url); + $defaultDomain = $parsed['host'] ?? ''; + + foreach ($headers as $header) { + try { + $cookie = Cookie::fromSetCookieHeader($header, $defaultDomain); + $this->setCookie($cookie); + } catch (\InvalidArgumentException $e) { + // Skip invalid cookies + continue; + } + } + + return $this; + } + + /** + * Get Cookie header value for a URL. + * + * Returns the string to be used in the Cookie request header. + * + * @param string $url The URL to get cookies for + * @param bool|null $isSecure Whether the request is secure + */ + public function getCookieHeaderForUrl(string $url, ?bool $isSecure = null): ?string + { + $cookies = $this->getCookiesForUrl($url, $isSecure); + + if (empty($cookies)) { + return null; + } + + $cookieStrings = array_map( + fn (Cookie $cookie) => $cookie->toCookieHeader(), + $cookies + ); + + return implode('; ', $cookieStrings); + } + + /** + * Export cookies to array format. + * + * @return array> + */ + public function toArray(): array + { + $this->removeExpiredCookies(); + + return array_map(function (Cookie $cookie) { + return [ + 'name' => $cookie->getName(), + 'value' => $cookie->getValue(), + 'expires_at' => $cookie->getExpiresAt(), + 'domain' => $cookie->getDomain(), + 'path' => $cookie->getPath(), + 'secure' => $cookie->isSecure(), + 'http_only' => $cookie->isHttpOnly(), + 'same_site' => $cookie->getSameSite(), + ]; + }, $this->cookies); + } + + /** + * Import cookies from array format. + * + * @param array> $data + * @return $this + */ + public function fromArray(array $data): self + { + foreach ($data as $item) { + try { + $cookie = new Cookie( + $item['name'], + $item['value'], + $item['expires_at'] ?? null, + $item['domain'] ?? null, + $item['path'] ?? '/', + $item['secure'] ?? false, + $item['http_only'] ?? false, + $item['same_site'] ?? null + ); + + $this->setCookie($cookie); + } catch (\Exception $e) { + // Skip invalid cookies + continue; + } + } + + return $this; + } + + /** + * Create a new cookie jar with session cookie persistence. + */ + public static function withSessionPersistence(): self + { + return new self(true); + } + + /** + * Create a new cookie jar without session cookie persistence. + */ + public static function withoutSessionPersistence(): self + { + return new self(false); + } +} diff --git a/src/Middleware/CookieMiddleware.php b/src/Middleware/CookieMiddleware.php new file mode 100644 index 0000000..2361889 --- /dev/null +++ b/src/Middleware/CookieMiddleware.php @@ -0,0 +1,124 @@ +addCookiesToRequest($request); + + // Send request and get response + $response = $next($request); + + // Extract and store cookies from response + $this->extractCookiesFromResponse($response, (string) $request->getUri()); + + return $response; + } + + /** + * Add cookies from jar to the request. + */ + private function addCookiesToRequest(RequestInterface $request): RequestInterface + { + $url = (string) $request->getUri(); + $scheme = $request->getUri()->getScheme(); + $isSecure = $scheme === 'https'; + + // Get cookie header value for this URL + $cookieHeader = $this->cookieJar->getCookieHeaderForUrl($url, $isSecure); + + if ($cookieHeader === null) { + return $request; + } + + // Check if request already has cookies + $existingCookies = $request->getHeaderLine('Cookie'); + + if ($existingCookies !== '') { + // Merge with existing cookies + $cookieHeader = $existingCookies.'; '.$cookieHeader; + } + + return $request->withHeader('Cookie', $cookieHeader); + } + + /** + * Extract cookies from response and add to jar. + */ + private function extractCookiesFromResponse(ResponseInterface $response, string $url): void + { + // Get all Set-Cookie headers + $setCookieHeaders = $response->getHeader('Set-Cookie'); + + if (empty($setCookieHeaders)) { + return; + } + + // Add cookies to jar + $this->cookieJar->addFromSetCookieHeaders($setCookieHeaders, $url); + } + + /** + * Get the cookie jar instance. + */ + public function getCookieJar(): CookieJar + { + return $this->cookieJar; + } + + /** + * Create a new middleware with a fresh cookie jar. + */ + public static function create(?CookieJar $cookieJar = null): self + { + return new self($cookieJar ?? new CookieJar); + } + + /** + * Create middleware with session cookie persistence. + */ + public static function withSessionPersistence(): self + { + return new self(CookieJar::withSessionPersistence()); + } +} diff --git a/src/Multipart/MultipartStreamBuilder.php b/src/Multipart/MultipartStreamBuilder.php new file mode 100644 index 0000000..817e79b --- /dev/null +++ b/src/Multipart/MultipartStreamBuilder.php @@ -0,0 +1,282 @@ + + */ + private array $parts = []; + + private readonly string $boundary; + + private readonly HttpFactory $httpFactory; + + /** + * Create a new multipart stream builder. + * + * @param string|null $boundary Optional custom boundary + * @param HttpFactory|null $httpFactory Optional HTTP factory + */ + public function __construct( + ?string $boundary = null, + ?HttpFactory $httpFactory = null + ) { + $this->boundary = $boundary ?? $this->generateBoundary(); + $this->httpFactory = $httpFactory ?? HttpFactory::getInstance(); + } + + /** + * Add a text field. + * + * @param string $name Field name + * @param string $value Field value + * @return $this + */ + public function addField(string $name, string $value): self + { + $this->parts[] = Part::text($name, $value); + + return $this; + } + + /** + * Add a file from a path. + * + * @param string $name Field name + * @param string $path File path + * @param string|null $filename Optional custom filename + * @param string|null $contentType Optional content type + * @return $this + * + * @throws \RuntimeException If file cannot be read + */ + public function addFile( + string $name, + string $path, + ?string $filename = null, + ?string $contentType = null + ): self { + if (! is_readable($path)) { + throw new \RuntimeException("File not readable: {$path}"); + } + + $filename = $filename ?? basename($path); + $stream = $this->httpFactory->createStreamFromFile($path, 'r'); + + $this->parts[] = Part::file($name, $stream, $filename, $contentType); + + return $this; + } + + /** + * Add a file from contents (string or stream). + * + * @param string $name Field name + * @param StreamInterface|string $contents File contents + * @param string $filename Filename + * @param string|null $contentType Optional content type + * @return $this + */ + public function addFileContents( + string $name, + StreamInterface|string $contents, + string $filename, + ?string $contentType = null + ): self { + $this->parts[] = Part::file($name, $contents, $filename, $contentType); + + return $this; + } + + /** + * Add a custom part. + * + * @param Part $part The part to add + * @return $this + */ + public function addPart(Part $part): self + { + $this->parts[] = $part; + + return $this; + } + + /** + * Add multiple parts from an array. + * + * Supports both formats: + * - ['name' => 'value', ...] for simple fields + * - [['name' => '...', 'contents' => '...', 'filename' => '...'], ...] + * + * @param array $parts + * @return $this + */ + public function addMultiple(array $parts): self + { + foreach ($parts as $key => $value) { + // Array format with keys + if (is_array($value) && isset($value['name'], $value['contents'])) { + $this->addFromArray($value); + } elseif (is_string($key)) { + // Simple key-value format + $this->addField($key, (string) $value); + } + } + + return $this; + } + + /** + * Get the boundary string. + */ + public function getBoundary(): string + { + return $this->boundary; + } + + /** + * Get the Content-Type header value. + */ + public function getContentType(): string + { + return 'multipart/form-data; boundary='.$this->boundary; + } + + /** + * Build the multipart stream. + * + * @return StreamInterface The complete multipart stream + */ + public function build(): StreamInterface + { + $content = ''; + + foreach ($this->parts as $part) { + $content .= $this->buildPartContent($part); + } + + // Add closing boundary + $content .= "--{$this->boundary}--\r\n"; + + return $this->httpFactory->createStream($content); + } + + /** + * Get all parts. + * + * @return array + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * Count the number of parts. + */ + public function count(): int + { + return count($this->parts); + } + + /** + * Build content for a single part. + */ + private function buildPartContent(Part $part): string + { + $content = "--{$this->boundary}\r\n"; + + // Add headers + foreach ($part->getHeaders() as $name => $value) { + $content .= "{$name}: {$value}\r\n"; + } + + $content .= "\r\n"; + + // Add content + $partContent = $part->getContents(); + if ($partContent instanceof StreamInterface) { + $content .= $partContent->getContents(); + } else { + $content .= $partContent; + } + + $content .= "\r\n"; + + return $content; + } + + /** + * Add a part from array configuration. + * + * @param array $config + */ + private function addFromArray(array $config): void + { + $name = (string) $config['name']; + $contents = $config['contents']; + $filename = $config['filename'] ?? null; + $contentType = $config['content-type'] ?? $config['contentType'] ?? null; + + // Convert string path to stream if it's a file path + if (is_string($contents) && isset($config['filename']) && is_file($contents)) { + $this->addFile($name, $contents, $filename, $contentType); + + return; + } + + // File with contents + if ($filename !== null) { + // Convert string to stream if needed + if (is_string($contents)) { + $contents = $this->httpFactory->createStream($contents); + } + + $this->addFileContents($name, $contents, $filename, $contentType); + + return; + } + + // Regular field + $this->addField($name, (string) $contents); + } + + /** + * Generate a unique boundary string. + */ + private function generateBoundary(): string + { + return '----TransportPHP'.bin2hex(random_bytes(16)); + } + + /** + * Create a new builder instance. + * + * @param string|null $boundary Optional custom boundary + */ + public static function create(?string $boundary = null): self + { + return new self($boundary); + } +} diff --git a/src/Multipart/Part.php b/src/Multipart/Part.php new file mode 100644 index 0000000..79b844b --- /dev/null +++ b/src/Multipart/Part.php @@ -0,0 +1,203 @@ + + */ + private array $headers = []; + + /** + * Create a new multipart part. + * + * @param string $name The field name + * @param StreamInterface|string|resource $contents The content stream or string + * @param string|null $filename Optional filename (for file uploads) + * @param array $headers Optional custom headers + */ + public function __construct( + private readonly string $name, + private readonly StreamInterface|string $contents, + private readonly ?string $filename = null, + array $headers = [] + ) { + $this->headers = $headers; + $this->initializeDefaultHeaders(); + } + + /** + * Get the field name. + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get the contents. + */ + public function getContents(): StreamInterface|string + { + return $this->contents; + } + + /** + * Get the filename (if this is a file upload). + */ + public function getFilename(): ?string + { + return $this->filename; + } + + /** + * Get all headers for this part. + * + * @return array + */ + public function getHeaders(): array + { + return $this->headers; + } + + /** + * Get a specific header value. + */ + public function getHeader(string $name): ?string + { + return $this->headers[$name] ?? null; + } + + /** + * Check if this part represents a file upload. + */ + public function isFile(): bool + { + return $this->filename !== null; + } + + /** + * Get the Content-Disposition header value. + */ + public function getContentDisposition(): string + { + $disposition = 'form-data; name="'.$this->escapeQuotes($this->name).'"'; + + if ($this->filename !== null) { + $disposition .= '; filename="'.$this->escapeQuotes($this->filename).'"'; + } + + return $disposition; + } + + /** + * Get the size of the content. + */ + public function getSize(): ?int + { + if ($this->contents instanceof StreamInterface) { + return $this->contents->getSize(); + } + + return strlen($this->contents); + } + + /** + * Initialize default headers based on content type. + */ + private function initializeDefaultHeaders(): void + { + // Set Content-Disposition + if (! isset($this->headers['Content-Disposition'])) { + $this->headers['Content-Disposition'] = $this->getContentDisposition(); + } + + // Set Content-Type for file uploads if not already set + if ($this->isFile() && ! isset($this->headers['Content-Type'])) { + $this->headers['Content-Type'] = $this->guessContentType(); + } + } + + /** + * Guess the content type based on filename. + */ + private function guessContentType(): string + { + if ($this->filename === null) { + return 'application/octet-stream'; + } + + $extension = strtolower(pathinfo($this->filename, PATHINFO_EXTENSION)); + + return match ($extension) { + 'jpg', 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'gif' => 'image/gif', + 'pdf' => 'application/pdf', + 'txt' => 'text/plain', + 'html', 'htm' => 'text/html', + 'json' => 'application/json', + 'xml' => 'application/xml', + 'zip' => 'application/zip', + 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + default => 'application/octet-stream', + }; + } + + /** + * Escape quotes in field/filename values. + */ + private function escapeQuotes(string $value): string + { + return str_replace('"', '\\"', $value); + } + + /** + * Create a text field part. + * + * @param string $name Field name + * @param string $value Field value + */ + public static function text(string $name, string $value): self + { + return new self($name, $value); + } + + /** + * Create a file upload part. + * + * @param string $name Field name + * @param StreamInterface|string $contents File contents + * @param string $filename Filename + * @param string|null $contentType Optional content type + */ + public static function file( + string $name, + StreamInterface|string $contents, + string $filename, + ?string $contentType = null + ): self { + $headers = []; + if ($contentType !== null) { + $headers['Content-Type'] = $contentType; + } + + return new self($name, $contents, $filename, $headers); + } +} diff --git a/src/RequestBuilder.php b/src/RequestBuilder.php index 88cf97c..100e055 100644 --- a/src/RequestBuilder.php +++ b/src/RequestBuilder.php @@ -7,6 +7,7 @@ use Farzai\Transport\Contracts\ResponseInterface; use Farzai\Transport\Contracts\SerializerInterface; use Farzai\Transport\Factory\HttpFactory; +use Farzai\Transport\Multipart\MultipartStreamBuilder; use Farzai\Transport\Serialization\SerializerFactory; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; @@ -133,6 +134,69 @@ public function withForm(array $data): self return $clone; } + /** + * Set multipart/form-data body. + * + * Supports multiple formats: + * - ['name' => 'value', ...] for simple fields + * - [['name' => '...', 'contents' => '...', 'filename' => '...'], ...] + * - [['name' => '...', 'contents' => fopen(...), 'filename' => '...'], ...] + * + * @param array $data Multipart data + * @param string|null $boundary Optional custom boundary + * @return $this + */ + public function withMultipart(array $data, ?string $boundary = null): self + { + $builder = new MultipartStreamBuilder($boundary, $this->httpFactory); + $builder->addMultiple($data); + + return $this->withMultipartBuilder($builder); + } + + /** + * Set multipart body using a builder. + * + * @param MultipartStreamBuilder $builder The multipart builder + * @return $this + */ + public function withMultipartBuilder(MultipartStreamBuilder $builder): self + { + $clone = clone $this; + $clone->body = $builder->build(); + $clone->headers['Content-Type'] = $builder->getContentType(); + + return $clone; + } + + /** + * Add a file to multipart request. + * + * This is a convenience method for single file uploads. + * + * @param string $name Field name + * @param string $path File path + * @param string|null $filename Optional custom filename + * @param array $additionalFields Additional form fields + * @return $this + */ + public function withFile( + string $name, + string $path, + ?string $filename = null, + array $additionalFields = [] + ): self { + $builder = new MultipartStreamBuilder(null, $this->httpFactory); + $builder->addFile($name, $path, $filename); + + // Add additional fields + foreach ($additionalFields as $fieldName => $fieldValue) { + $builder->addField($fieldName, (string) $fieldValue); + } + + return $this->withMultipartBuilder($builder); + } + /** * Add query parameters. * diff --git a/src/TransportBuilder.php b/src/TransportBuilder.php index 7287795..f88f240 100644 --- a/src/TransportBuilder.php +++ b/src/TransportBuilder.php @@ -4,7 +4,9 @@ namespace Farzai\Transport; +use Farzai\Transport\Cookie\CookieJar; use Farzai\Transport\Factory\ClientFactory; +use Farzai\Transport\Middleware\CookieMiddleware; use Farzai\Transport\Middleware\LoggingMiddleware; use Farzai\Transport\Middleware\MiddlewareInterface; use Farzai\Transport\Middleware\RetryMiddleware; @@ -44,6 +46,8 @@ final class TransportBuilder private bool $useDefaultMiddlewares = true; + private ?CookieJar $cookieJar = null; + /** * Create a new builder instance. */ @@ -147,6 +151,30 @@ public function withoutDefaultMiddlewares(): self return $clone; } + /** + * Enable automatic cookie handling with a cookie jar. + * + * @param CookieJar|null $cookieJar Optional cookie jar (creates new if null) + * @return $this + */ + public function withCookieJar(?CookieJar $cookieJar = null): self + { + $clone = clone $this; + $clone->cookieJar = $cookieJar ?? new CookieJar; + + return $clone; + } + + /** + * Enable automatic cookie handling (shortcut). + * + * @return $this + */ + public function withCookies(): self + { + return $this->withCookieJar(); + } + /** * Get the configured client. */ @@ -201,6 +229,11 @@ private function buildMiddlewares(LoggerInterface $logger): array { $middlewares = []; + // Add cookie middleware first if configured + if ($this->cookieJar !== null) { + $middlewares[] = new CookieMiddleware($this->cookieJar); + } + if ($this->useDefaultMiddlewares) { // Add logging middleware $middlewares[] = new LoggingMiddleware($logger); diff --git a/tests/Cookie/CookieTest.php b/tests/Cookie/CookieTest.php new file mode 100644 index 0000000..143ce96 --- /dev/null +++ b/tests/Cookie/CookieTest.php @@ -0,0 +1,391 @@ +getName())->toBe('session_id'); + expect($cookie->getValue())->toBe('abc123'); + expect($cookie->getPath())->toBe('/'); + expect($cookie->getDomain())->toBeNull(); + expect($cookie->isSecure())->toBeFalse(); + expect($cookie->isHttpOnly())->toBeFalse(); + }); + + it('creates a cookie with all attributes', function () { + $expiresAt = time() + 3600; + $cookie = new Cookie( + 'auth_token', + 'xyz789', + $expiresAt, + 'example.com', + '/api', + true, + true, + 'Strict' + ); + + expect($cookie->getName())->toBe('auth_token'); + expect($cookie->getValue())->toBe('xyz789'); + expect($cookie->getExpiresAt())->toBe($expiresAt); + expect($cookie->getDomain())->toBe('example.com'); + expect($cookie->getPath())->toBe('/api'); + expect($cookie->isSecure())->toBeTrue(); + expect($cookie->isHttpOnly())->toBeTrue(); + expect($cookie->getSameSite())->toBe('Strict'); + }); + + it('identifies session cookies', function () { + $sessionCookie = new Cookie('session', 'value'); + $persistentCookie = new Cookie('remember', 'value', time() + 3600); + + expect($sessionCookie->isSessionCookie())->toBeTrue(); + expect($persistentCookie->isSessionCookie())->toBeFalse(); + }); + + it('checks if cookie is expired', function () { + $expiredCookie = new Cookie('old', 'value', time() - 3600); + $validCookie = new Cookie('new', 'value', time() + 3600); + + expect($expiredCookie->isExpired())->toBeTrue(); + expect($validCookie->isExpired())->toBeFalse(); + }); + + it('matches exact domain', function () { + $cookie = new Cookie('test', 'value', null, 'example.com'); + + expect($cookie->matchesDomain('example.com'))->toBeTrue(); + expect($cookie->matchesDomain('www.example.com'))->toBeTrue(); + expect($cookie->matchesDomain('other.com'))->toBeFalse(); + }); + + it('matches subdomain with dot prefix', function () { + $cookie = new Cookie('test', 'value', null, '.example.com'); + + expect($cookie->matchesDomain('example.com'))->toBeTrue(); + expect($cookie->matchesDomain('www.example.com'))->toBeTrue(); + expect($cookie->matchesDomain('api.example.com'))->toBeTrue(); + expect($cookie->matchesDomain('other.com'))->toBeFalse(); + }); + + it('matches path correctly', function () { + $cookie = new Cookie('test', 'value', null, null, '/api'); + + expect($cookie->matchesPath('/api'))->toBeTrue(); + expect($cookie->matchesPath('/api/users'))->toBeTrue(); + expect($cookie->matchesPath('/api/v2/users'))->toBeTrue(); + expect($cookie->matchesPath('/app'))->toBeFalse(); + expect($cookie->matchesPath('/apiv2'))->toBeFalse(); + }); + + it('matches path with trailing slash', function () { + $cookie = new Cookie('test', 'value', null, null, '/api/'); + + expect($cookie->matchesPath('/api/'))->toBeTrue(); + expect($cookie->matchesPath('/api/users'))->toBeTrue(); + }); + + it('matches URL correctly', function () { + $cookie = new Cookie('test', 'value', null, 'example.com', '/api', true); + + expect($cookie->matchesUrl('https://example.com/api', true))->toBeTrue(); + expect($cookie->matchesUrl('https://example.com/api/users', true))->toBeTrue(); + expect($cookie->matchesUrl('http://example.com/api', false))->toBeFalse(); // Secure cookie on non-secure request + expect($cookie->matchesUrl('https://other.com/api', true))->toBeFalse(); + }); + + it('generates Set-Cookie header', function () { + $cookie = new Cookie('session_id', 'abc123', null, 'example.com', '/', true, true, 'Lax'); + + $header = $cookie->toSetCookieHeader(); + + expect($header)->toContain('session_id=abc123'); + expect($header)->toContain('Domain=example.com'); + expect($header)->toContain('Secure'); + expect($header)->toContain('HttpOnly'); + expect($header)->toContain('SameSite=Lax'); + }); + + it('generates Cookie header for request', function () { + $cookie = new Cookie('token', 'xyz789'); + + expect($cookie->toCookieHeader())->toBe('token=xyz789'); + }); + + it('creates cookie with updated value', function () { + $original = new Cookie('counter', '1', null, 'example.com'); + $updated = $original->withValue('2'); + + expect($original->getValue())->toBe('1'); + expect($updated->getValue())->toBe('2'); + expect($updated->getDomain())->toBe('example.com'); + }); + + it('parses Set-Cookie header', function () { + $header = 'session_id=abc123; Expires=Wed, 21 Oct 2025 07:28:00 GMT; Domain=example.com; Path=/; Secure; HttpOnly; SameSite=Strict'; + + $cookie = Cookie::fromSetCookieHeader($header); + + expect($cookie->getName())->toBe('session_id'); + expect($cookie->getValue())->toBe('abc123'); + expect($cookie->getDomain())->toBe('example.com'); + expect($cookie->getPath())->toBe('/'); + expect($cookie->isSecure())->toBeTrue(); + expect($cookie->isHttpOnly())->toBeTrue(); + expect($cookie->getSameSite())->toBe('Strict'); + }); + + it('parses Set-Cookie header with Max-Age', function () { + $header = 'token=xyz; Max-Age=3600; Domain=example.com'; + + $cookie = Cookie::fromSetCookieHeader($header); + + expect($cookie->getName())->toBe('token'); + expect($cookie->getExpiresAt())->toBeGreaterThan(time()); + }); + + it('throws exception for invalid cookie name', function () { + expect(fn () => new Cookie('invalid name', 'value')) + ->toThrow(\InvalidArgumentException::class); + + expect(fn () => new Cookie('invalid;name', 'value')) + ->toThrow(\InvalidArgumentException::class); + }); + + it('throws exception for invalid SameSite value', function () { + expect(fn () => new Cookie('test', 'value', null, null, '/', false, false, 'Invalid')) + ->toThrow(\InvalidArgumentException::class); + }); + + it('generates unique identifier', function () { + $cookie1 = new Cookie('session', 'value', null, 'example.com', '/'); + $cookie2 = new Cookie('session', 'value', null, 'example.com', '/api'); + $cookie3 = new Cookie('session', 'value', null, 'other.com', '/'); + + expect($cookie1->getIdentifier())->not->toBe($cookie2->getIdentifier()); + expect($cookie1->getIdentifier())->not->toBe($cookie3->getIdentifier()); + }); + + it('converts domain to lowercase', function () { + $cookie = new Cookie('test', 'value', null, 'Example.COM'); + + expect($cookie->getDomain())->toBe('example.com'); + }); + + it('matchesDomain returns true when domain is null', function () { + $cookie = new Cookie('test', 'value', null, null); // domain is null + + // When cookie has no domain, it returns true (jar handles origin matching) + expect($cookie->matchesDomain('example.com'))->toBeTrue(); + expect($cookie->matchesDomain('other.com'))->toBeTrue(); + }); +}); + +describe('CookieJar', function () { + it('stores and retrieves cookies', function () { + $jar = new CookieJar; + $cookie = new Cookie('session', 'abc123', null, 'example.com'); + + $jar->setCookie($cookie); + + $retrieved = $jar->getCookie('session', 'example.com'); + expect($retrieved)->not->toBeNull(); + expect($retrieved->getValue())->toBe('abc123'); + }); + + it('replaces cookie with same name, domain, and path', function () { + $jar = new CookieJar; + $cookie1 = new Cookie('token', 'old', null, 'example.com'); + $cookie2 = new Cookie('token', 'new', null, 'example.com'); + + $jar->setCookie($cookie1); + $jar->setCookie($cookie2); + + $retrieved = $jar->getCookie('token', 'example.com'); + expect($retrieved->getValue())->toBe('new'); + expect($jar->count())->toBe(1); + }); + + it('stores multiple cookies with different attributes', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('token', 'value1', null, 'example.com', '/')); + $jar->setCookie(new Cookie('token', 'value2', null, 'example.com', '/api')); + $jar->setCookie(new Cookie('session', 'value3', null, 'other.com', '/')); + + expect($jar->count())->toBe(3); + }); + + it('gets cookies for URL', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('cookie1', 'value1', null, 'example.com', '/')); + $jar->setCookie(new Cookie('cookie2', 'value2', null, 'example.com', '/api')); + $jar->setCookie(new Cookie('cookie3', 'value3', null, 'other.com', '/')); + + $cookies = $jar->getCookiesForUrl('https://example.com/api/users'); + + expect($cookies)->toHaveCount(2); + expect($cookies[0]->getName())->toBe('cookie2'); // More specific path first + expect($cookies[1]->getName())->toBe('cookie1'); + }); + + it('respects secure flag in URL matching', function () { + $jar = new CookieJar; + $secureCookie = new Cookie('secure', 'value', null, 'example.com', '/', true); + + $jar->setCookie($secureCookie); + + $httpsMatches = $jar->getCookiesForUrl('https://example.com/', true); + $httpMatches = $jar->getCookiesForUrl('http://example.com/', false); + + expect($httpsMatches)->toHaveCount(1); + expect($httpMatches)->toHaveCount(0); + }); + + it('removes expired cookies automatically', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('expired', 'value', time() - 3600)); + $jar->setCookie(new Cookie('valid', 'value', time() + 3600)); + + expect($jar->count())->toBe(1); + }); + + it('removes specific cookie', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('token', 'value', null, 'example.com')); + + $jar->removeCookie('token', 'example.com'); + + expect($jar->count())->toBe(0); + }); + + it('clears all cookies', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('cookie1', 'value1')); + $jar->setCookie(new Cookie('cookie2', 'value2')); + + $jar->clear(); + + expect($jar->count())->toBe(0); + expect($jar->isEmpty())->toBeTrue(); + }); + + it('adds cookies from Set-Cookie headers', function () { + $jar = new CookieJar; + $headers = [ + 'session_id=abc123; Path=/; HttpOnly', + 'token=xyz789; Domain=example.com; Secure', + ]; + + $jar->addFromSetCookieHeaders($headers, 'https://example.com'); + + expect($jar->count())->toBe(2); + }); + + it('generates Cookie header for URL', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('session', 'abc123', null, 'example.com')); + $jar->setCookie(new Cookie('token', 'xyz789', null, 'example.com')); + + $header = $jar->getCookieHeaderForUrl('https://example.com/'); + + expect($header)->toContain('session=abc123'); + expect($header)->toContain('token=xyz789'); + expect($header)->toContain('; '); + }); + + it('returns null when no cookies match URL', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('test', 'value', null, 'example.com')); + + $header = $jar->getCookieHeaderForUrl('https://other.com/'); + + expect($header)->toBeNull(); + }); + + it('exports and imports cookies', function () { + $jar1 = new CookieJar; + $jar1->setCookie(new Cookie('cookie1', 'value1', null, 'example.com')); + $jar1->setCookie(new Cookie('cookie2', 'value2', time() + 3600, 'example.com')); + + $data = $jar1->toArray(); + + $jar2 = new CookieJar; + $jar2->fromArray($data); + + expect($jar2->count())->toBe(2); + expect($jar2->getCookie('cookie1', 'example.com')->getValue())->toBe('value1'); + }); + + it('handles session cookie persistence', function () { + $jar = CookieJar::withSessionPersistence(); + $sessionCookie = new Cookie('session', 'value'); + + $jar->setCookie($sessionCookie); + + expect($jar->count())->toBe(1); + }); + + it('skips invalid cookies when adding from headers', function () { + $jar = new CookieJar; + $headers = [ + 'valid=value; Path=/', + '', // Empty header + 'invalid', // No value + ]; + + $jar->addFromSetCookieHeaders($headers, 'https://example.com'); + + // Should only add the valid cookie + expect($jar->count())->toBe(1); + }); + + it('sorts cookies by path specificity', function () { + $jar = new CookieJar; + + $jar->setCookie(new Cookie('root', 'value', null, 'example.com', '/')); + $jar->setCookie(new Cookie('api', 'value', null, 'example.com', '/api')); + $jar->setCookie(new Cookie('users', 'value', null, 'example.com', '/api/users')); + + $cookies = $jar->getCookiesForUrl('https://example.com/api/users/123'); + + // More specific paths should come first + expect($cookies[0]->getName())->toBe('users'); + expect($cookies[1]->getName())->toBe('api'); + expect($cookies[2]->getName())->toBe('root'); + }); + + it('handles invalid URL in getCookiesForUrl', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('test', 'value', null, 'example.com')); + + // Test with an invalid URL that would cause parse_url to return false + $cookies = $jar->getCookiesForUrl('http:///invalid'); + + expect($cookies)->toBe([]); + }); + + it('getAllCookies can include expired cookies', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('valid', 'value', time() + 3600)); + $jar->setCookie(new Cookie('expired', 'value', time() - 3600)); + + // By default, expired cookies are removed + $cookies = $jar->getAllCookies(); + expect($cookies)->toHaveCount(1); + expect($cookies[0]->getName())->toBe('valid'); + + // With includeExpired=true, should get both + $allCookies = $jar->getAllCookies(true); + expect($allCookies)->toHaveCount(1); // expired was already removed + }); +}); diff --git a/tests/Middleware/CookieMiddlewareTest.php b/tests/Middleware/CookieMiddlewareTest.php new file mode 100644 index 0000000..c4b5256 --- /dev/null +++ b/tests/Middleware/CookieMiddlewareTest.php @@ -0,0 +1,272 @@ +setCookie(new Cookie('session_id', 'abc123', null, 'example.com', '/')); + + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/api'); + $middleware->handle($request, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe('session_id=abc123'); + + return new Response(200); + }); + }); + + it('extracts cookies from Set-Cookie response headers', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/login'); + $response = new Response(200, [ + 'Set-Cookie' => 'auth_token=xyz789; Path=/; HttpOnly', + ]); + + $middleware->handle($request, fn () => $response); + + $cookies = $jar->getAllCookies(); + expect($cookies)->toHaveCount(1); + expect($cookies[0]->getName())->toBe('auth_token'); + expect($cookies[0]->getValue())->toBe('xyz789'); + }); + + it('merges with existing Cookie header', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('session_id', 'abc123', null, 'example.com', '/')); + + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/api', [ + 'Cookie' => 'user_pref=dark_mode', + ]); + + $middleware->handle($request, function ($req) { + $cookieHeader = $req->getHeaderLine('Cookie'); + expect($cookieHeader)->toContain('user_pref=dark_mode'); + expect($cookieHeader)->toContain('session_id=abc123'); + + return new Response(200); + }); + }); + + it('handles requests with no matching cookies', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('session', 'value', null, 'other.com', '/')); + + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/api'); + $middleware->handle($request, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe(''); + + return new Response(200); + }); + }); + + it('handles responses with no Set-Cookie headers', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/api'); + $response = new Response(200); + + $middleware->handle($request, fn () => $response); + + expect($jar->getAllCookies())->toHaveCount(0); + }); + + it('respects secure flag for HTTPS requests', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('secure_token', 'secret', null, 'example.com', '/', true)); + + $middleware = new CookieMiddleware($jar); + + // HTTPS request should include secure cookie + $httpsRequest = new Request('GET', 'https://example.com/api'); + $middleware->handle($httpsRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe('secure_token=secret'); + + return new Response(200); + }); + }); + + it('excludes secure cookies from HTTP requests', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('secure_token', 'secret', null, 'example.com', '/', true)); + + $middleware = new CookieMiddleware($jar); + + // HTTP request should NOT include secure cookie + $httpRequest = new Request('GET', 'http://example.com/api'); + $middleware->handle($httpRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe(''); + + return new Response(200); + }); + }); + + it('handles multiple Set-Cookie headers', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + $request = new Request('GET', 'https://example.com/login'); + $response = new Response(200, [ + 'Set-Cookie' => [ + 'session_id=abc123; Path=/', + 'user_id=42; Path=/; HttpOnly', + 'remember=true; Path=/; Max-Age=2592000', + ], + ]); + + $middleware->handle($request, fn () => $response); + + $cookies = $jar->getAllCookies(); + expect($cookies)->toHaveCount(3); + expect($cookies[0]->getName())->toBe('session_id'); + expect($cookies[1]->getName())->toBe('user_id'); + expect($cookies[2]->getName())->toBe('remember'); + }); + + it('returns the cookie jar instance', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + expect($middleware->getCookieJar())->toBe($jar); + }); + + it('can create middleware with default cookie jar', function () { + $middleware = CookieMiddleware::create(); + + expect($middleware->getCookieJar())->toBeInstanceOf(CookieJar::class); + }); + + it('can create middleware with custom cookie jar', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('existing', 'cookie', null, 'example.com', '/')); + + $middleware = CookieMiddleware::create($jar); + + expect($middleware->getCookieJar())->toBe($jar); + expect($jar->getAllCookies())->toHaveCount(1); + }); + + it('can create middleware with session persistence', function () { + $middleware = CookieMiddleware::withSessionPersistence(); + + expect($middleware->getCookieJar())->toBeInstanceOf(CookieJar::class); + + // Verify it includes session cookies + $jar = $middleware->getCookieJar(); + $request = new Request('GET', 'https://example.com/api'); + $response = new Response(200, [ + 'Set-Cookie' => 'session=value; Path=/', + ]); + + $middleware->handle($request, fn () => $response); + + expect($jar->getAllCookies())->toHaveCount(1); + }); + + it('handles complete request-response cycle with cookies', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + // First request: receive cookie from server + $request1 = new Request('GET', 'https://example.com/login'); + $response1 = new Response(200, [ + 'Set-Cookie' => 'session_id=abc123; Path=/; HttpOnly', + ]); + + $middleware->handle($request1, fn () => $response1); + + // Second request: cookie should be sent automatically + $request2 = new Request('GET', 'https://example.com/api/user'); + $middleware->handle($request2, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe('session_id=abc123'); + + return new Response(200); + }); + }); + + it('handles cookie updates from server', function () { + $jar = new CookieJar; + $middleware = new CookieMiddleware($jar); + + // First request: receive initial cookie + $request1 = new Request('GET', 'https://example.com/api'); + $response1 = new Response(200, [ + 'Set-Cookie' => 'token=old_value; Path=/', + ]); + + $middleware->handle($request1, fn () => $response1); + + // Second request: server updates cookie + $request2 = new Request('GET', 'https://example.com/api'); + $response2 = new Response(200, [ + 'Set-Cookie' => 'token=new_value; Path=/', + ]); + + $middleware->handle($request2, fn () => $response2); + + // Verify cookie was updated + $cookies = $jar->getAllCookies(); + expect($cookies)->toHaveCount(1); + expect($cookies[0]->getValue())->toBe('new_value'); + }); + + it('respects path restrictions', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('admin_token', 'secret', null, 'example.com', '/admin')); + + $middleware = new CookieMiddleware($jar); + + // Request to /admin should include cookie + $adminRequest = new Request('GET', 'https://example.com/admin/users'); + $middleware->handle($adminRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe('admin_token=secret'); + + return new Response(200); + }); + + // Request to / should NOT include cookie + $rootRequest = new Request('GET', 'https://example.com/'); + $middleware->handle($rootRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe(''); + + return new Response(200); + }); + }); + + it('respects domain restrictions', function () { + $jar = new CookieJar; + $jar->setCookie(new Cookie('site_token', 'value', null, 'site.com', '/')); + + $middleware = new CookieMiddleware($jar); + + // Request to site.com should include cookie + $matchingRequest = new Request('GET', 'https://site.com/api'); + $middleware->handle($matchingRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe('site_token=value'); + + return new Response(200); + }); + + // Request to other.com should NOT include cookie + $nonMatchingRequest = new Request('GET', 'https://other.com/api'); + $middleware->handle($nonMatchingRequest, function ($req) { + expect($req->getHeaderLine('Cookie'))->toBe(''); + + return new Response(200); + }); + }); +}); diff --git a/tests/Multipart/MultipartTest.php b/tests/Multipart/MultipartTest.php new file mode 100644 index 0000000..c56e9f0 --- /dev/null +++ b/tests/Multipart/MultipartTest.php @@ -0,0 +1,322 @@ +httpFactory = HttpFactory::getInstance(); +}); + +describe('Part', function () { + it('creates a text field part', function () { + $part = Part::text('username', 'john_doe'); + + expect($part->getName())->toBe('username'); + expect($part->getContents())->toBe('john_doe'); + expect($part->isFile())->toBeFalse(); + expect($part->getFilename())->toBeNull(); + }); + + it('creates a file part', function () { + $content = 'file content'; + $part = Part::file('document', $content, 'test.txt', 'text/plain'); + + expect($part->getName())->toBe('document'); + expect($part->getFilename())->toBe('test.txt'); + expect($part->isFile())->toBeTrue(); + expect($part->getHeader('Content-Type'))->toBe('text/plain'); + }); + + it('generates correct Content-Disposition header for text field', function () { + $part = Part::text('field', 'value'); + + expect($part->getContentDisposition())->toBe('form-data; name="field"'); + }); + + it('generates correct Content-Disposition header for file', function () { + $part = Part::file('upload', 'content', 'document.pdf'); + + expect($part->getContentDisposition())->toBe('form-data; name="upload"; filename="document.pdf"'); + }); + + it('escapes quotes in field names and filenames', function () { + $part = Part::file('field"name', 'content', 'file"name.txt'); + + expect($part->getContentDisposition())->toContain('name="field\\"name"'); + expect($part->getContentDisposition())->toContain('filename="file\\"name.txt"'); + }); + + it('guesses content type from file extension', function () { + $tests = [ + 'image.jpg' => 'image/jpeg', + 'document.pdf' => 'application/pdf', + 'data.json' => 'application/json', + 'archive.zip' => 'application/zip', + 'unknown.xyz' => 'application/octet-stream', + ]; + + foreach ($tests as $filename => $expectedType) { + $part = Part::file('file', 'content', $filename); + expect($part->getHeader('Content-Type'))->toBe($expectedType); + } + }); + + it('returns size for string content', function () { + $content = 'Hello World'; + $part = Part::text('field', $content); + + expect($part->getSize())->toBe(strlen($content)); + }); + + it('allows custom headers', function () { + $part = new Part('field', 'value', null, ['X-Custom' => 'test']); + + expect($part->getHeader('X-Custom'))->toBe('test'); + }); + + it('returns size for StreamInterface content', function () { + $httpFactory = HttpFactory::getInstance(); + $stream = $httpFactory->createStream('Stream content here'); + $part = new Part('field', $stream); + + expect($part->getSize())->toBe(19); // Length of "Stream content here" + }); + + it('guesses content type as octet-stream when filename is null', function () { + // Create a file part, but we'll check the guessContentType behavior + // This tests the null check in guessContentType (line 140) + $part = new Part('field', 'content', 'file.unknown'); + + // This should return application/octet-stream for unknown extensions + expect($part->getHeader('Content-Type'))->toBe('application/octet-stream'); + }); + + it('guesses content type for various file extensions', function () { + $tests = [ + 'document.txt' => 'text/plain', + 'page.html' => 'text/html', + 'page.htm' => 'text/html', + 'data.xml' => 'application/xml', + 'photo.gif' => 'image/gif', + 'photo.png' => 'image/png', + 'photo.jpeg' => 'image/jpeg', + 'report.doc' => 'application/msword', + 'report.docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'spreadsheet.xls' => 'application/vnd.ms-excel', + 'spreadsheet.xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ]; + + foreach ($tests as $filename => $expectedType) { + $part = Part::file('file', 'content', $filename); + expect($part->getHeader('Content-Type'))->toBe($expectedType, "Failed for $filename"); + } + }); + + it('handles file part without explicit content type', function () { + // When content type is not provided, it should be guessed from extension + $part = Part::file('upload', 'content', 'document.pdf'); + + expect($part->getHeader('Content-Type'))->toBe('application/pdf'); + }); + + it('returns all headers including Content-Disposition', function () { + $part = Part::file('upload', 'content', 'test.pdf', 'application/pdf'); + + $headers = $part->getHeaders(); + expect($headers)->toHaveKey('Content-Disposition'); + expect($headers)->toHaveKey('Content-Type'); + expect($headers['Content-Type'])->toBe('application/pdf'); + }); +}); + +describe('MultipartStreamBuilder', function () { + it('generates unique boundary', function () { + $builder1 = new MultipartStreamBuilder; + $builder2 = new MultipartStreamBuilder; + + expect($builder1->getBoundary())->not->toBe($builder2->getBoundary()); + }); + + it('accepts custom boundary', function () { + $boundary = 'custom-boundary-123'; + $builder = new MultipartStreamBuilder($boundary); + + expect($builder->getBoundary())->toBe($boundary); + }); + + it('adds text fields', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addField('name', 'John Doe') + ->addField('email', 'john@example.com'); + + expect($builder->count())->toBe(2); + expect($builder->getParts()[0]->getName())->toBe('name'); + expect($builder->getParts()[1]->getName())->toBe('email'); + }); + + it('builds valid multipart stream', function () { + $builder = new MultipartStreamBuilder('boundary123'); + $builder->addField('username', 'johndoe'); + + $stream = $builder->build(); + $content = $stream->getContents(); + + expect($content)->toContain('--boundary123'); + expect($content)->toContain('Content-Disposition: form-data; name="username"'); + expect($content)->toContain('johndoe'); + expect($content)->toContain('--boundary123--'); + }); + + it('returns correct Content-Type header', function () { + $builder = new MultipartStreamBuilder('test-boundary'); + + expect($builder->getContentType())->toBe('multipart/form-data; boundary=test-boundary'); + }); + + it('adds file from contents', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addFileContents('document', 'PDF content here', 'report.pdf', 'application/pdf'); + + $parts = $builder->getParts(); + expect($parts[0]->getName())->toBe('document'); + expect($parts[0]->getFilename())->toBe('report.pdf'); + expect($parts[0]->isFile())->toBeTrue(); + }); + + it('adds multiple parts from array with simple format', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addMultiple([ + 'name' => 'John', + 'age' => '30', + ]); + + expect($builder->count())->toBe(2); + }); + + it('adds multiple parts from array with complex format', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addMultiple([ + ['name' => 'username', 'contents' => 'john'], + ['name' => 'avatar', 'contents' => 'image data', 'filename' => 'avatar.jpg'], + ]); + + $parts = $builder->getParts(); + expect($parts[0]->getName())->toBe('username'); + expect($parts[0]->isFile())->toBeFalse(); + expect($parts[1]->getName())->toBe('avatar'); + expect($parts[1]->isFile())->toBeTrue(); + }); + + it('builds multipart stream with multiple parts', function () { + $builder = new MultipartStreamBuilder('boundary456'); + $builder->addField('name', 'John Doe') + ->addFileContents('file', 'content', 'test.txt'); + + $stream = $builder->build(); + $content = $stream->getContents(); + + // Check structure + expect($content)->toContain('--boundary456'); + expect($content)->toContain('name="name"'); + expect($content)->toContain('John Doe'); + expect($content)->toContain('name="file"'); + expect($content)->toContain('filename="test.txt"'); + expect($content)->toContain('content'); + expect($content)->toContain('--boundary456--'); + }); + + it('properly formats multipart boundaries and headers', function () { + $builder = new MultipartStreamBuilder('test'); + $builder->addField('key', 'value'); + + $stream = $builder->build(); + $content = $stream->getContents(); + + // Check RFC 7578 compliance + expect($content)->toStartWith('--test'); + expect($content)->toContain("\r\n"); + expect($content)->toEndWith("--test--\r\n"); + }); + + it('creates builder with static factory method', function () { + $builder = MultipartStreamBuilder::create('custom'); + + expect($builder)->toBeInstanceOf(MultipartStreamBuilder::class); + expect($builder->getBoundary())->toBe('custom'); + }); + + it('handles content type aliases in array format', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addMultiple([ + ['name' => 'file', 'contents' => 'data', 'filename' => 'doc.pdf', 'contentType' => 'application/pdf'], + ]); + + $part = $builder->getParts()[0]; + expect($part->getHeader('Content-Type'))->toBe('application/pdf'); + }); + + it('counts parts correctly', function () { + $builder = MultipartStreamBuilder::create(); + + expect($builder->count())->toBe(0); + + $builder->addField('field1', 'value1'); + expect($builder->count())->toBe(1); + + $builder->addField('field2', 'value2'); + expect($builder->count())->toBe(2); + }); + + it('throws exception when adding non-readable file', function () { + $builder = MultipartStreamBuilder::create(); + + expect(fn () => $builder->addFile('file', '/non/existent/path.txt')) + ->toThrow(\RuntimeException::class, 'File not readable'); + }); +}); + +describe('MultipartStreamBuilder with real files', function () { + beforeEach(function () { + // Create a temporary test file + $this->tempFile = sys_get_temp_dir().'/test_upload_'.uniqid().'.txt'; + file_put_contents($this->tempFile, 'Test file content'); + }); + + afterEach(function () { + // Clean up + if (file_exists($this->tempFile)) { + unlink($this->tempFile); + } + }); + + it('adds file from path', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addFile('document', $this->tempFile, 'custom.txt'); + + $parts = $builder->getParts(); + expect($parts[0]->getFilename())->toBe('custom.txt'); + expect($parts[0]->isFile())->toBeTrue(); + }); + + it('uses basename as filename if not provided', function () { + $builder = MultipartStreamBuilder::create(); + $builder->addFile('document', $this->tempFile); + + $parts = $builder->getParts(); + expect($parts[0]->getFilename())->toBe(basename($this->tempFile)); + }); + + it('builds valid multipart stream from file', function () { + $builder = new MultipartStreamBuilder('testboundary'); + $builder->addFile('upload', $this->tempFile, 'myfile.txt'); + + $stream = $builder->build(); + $content = $stream->getContents(); + + expect($content)->toContain('Test file content'); + expect($content)->toContain('filename="myfile.txt"'); + }); +}); diff --git a/tests/RequestBuilderTest.php b/tests/RequestBuilderTest.php index c69786a..6f28465 100644 --- a/tests/RequestBuilderTest.php +++ b/tests/RequestBuilderTest.php @@ -159,6 +159,131 @@ expect(fn () => $builder->send()) ->toThrow(RuntimeException::class, 'No transport instance available'); }); + + it('can set multipart form data', function () { + $request = RequestBuilder::post('/upload') + ->withMultipart([ + ['name' => 'username', 'contents' => 'john_doe'], + ['name' => 'email', 'contents' => 'john@example.com'], + ]) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + expect($request->getBody()->getSize())->toBeGreaterThan(0); + }); + + it('can set multipart form data with simple array', function () { + $request = RequestBuilder::post('/upload') + ->withMultipart([ + 'username' => 'john_doe', + 'email' => 'john@example.com', + ]) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + expect($request->getBody()->getSize())->toBeGreaterThan(0); + }); + + it('can set multipart form data with custom boundary', function () { + $customBoundary = 'my-custom-boundary-123'; + $request = RequestBuilder::post('/upload') + ->withMultipart([ + 'field' => 'value', + ], $customBoundary) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toBe("multipart/form-data; boundary={$customBoundary}"); + }); + + it('can set multipart using builder', function () { + $builder = \Farzai\Transport\Multipart\MultipartStreamBuilder::create(); + $builder->addField('username', 'john_doe'); + $builder->addField('email', 'john@example.com'); + + $request = RequestBuilder::post('/upload') + ->withMultipartBuilder($builder) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + expect($request->getBody()->getSize())->toBeGreaterThan(0); + }); + + it('can upload file with withFile method', function () { + // Create a temporary file for testing + $tempFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($tempFile, 'test file content'); + + try { + $request = RequestBuilder::post('/upload') + ->withFile('document', $tempFile) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + expect($request->getBody()->getSize())->toBeGreaterThan(0); + + // Verify the body contains the file content + $bodyContent = $request->getBody()->getContents(); + expect($bodyContent)->toContain('test file content'); + } finally { + // Clean up + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + }); + + it('can upload file with custom filename', function () { + // Create a temporary file for testing + $tempFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($tempFile, 'test file content'); + + try { + $request = RequestBuilder::post('/upload') + ->withFile('document', $tempFile, 'custom-filename.txt') + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + + // Verify the body contains the custom filename + $bodyContent = $request->getBody()->getContents(); + expect($bodyContent)->toContain('custom-filename.txt'); + } finally { + // Clean up + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + }); + + it('can upload file with additional fields', function () { + // Create a temporary file for testing + $tempFile = tempnam(sys_get_temp_dir(), 'test_upload_'); + file_put_contents($tempFile, 'test file content'); + + try { + $request = RequestBuilder::post('/upload') + ->withFile('document', $tempFile, null, [ + 'description' => 'My document', + 'category' => 'important', + ]) + ->build(); + + expect($request->getHeaderLine('Content-Type'))->toContain('multipart/form-data'); + + // Verify the body contains both the file and additional fields + $bodyContent = $request->getBody()->getContents(); + expect($bodyContent)->toContain('test file content'); + expect($bodyContent)->toContain('description'); + expect($bodyContent)->toContain('My document'); + expect($bodyContent)->toContain('category'); + expect($bodyContent)->toContain('important'); + } finally { + // Clean up + if (file_exists($tempFile)) { + unlink($tempFile); + } + } + }); }); describe('RequestBuilder with Transport', function () { diff --git a/tests/TransportTest.php b/tests/TransportTest.php index 8b26fe8..6b04a2c 100644 --- a/tests/TransportTest.php +++ b/tests/TransportTest.php @@ -126,6 +126,69 @@ $request = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com'); $transport->sendRequest($request); }); + + it('can enable cookie jar', function () { + $cookieJar = new \Farzai\Transport\Cookie\CookieJar; + $builder = TransportBuilder::make()->withCookieJar($cookieJar); + + $transport = $builder->build(); + + expect($transport)->toBeInstanceOf(Transport::class); + // The cookie jar should be added via CookieMiddleware + }); + + it('can enable cookie jar with default instance', function () { + $builder = TransportBuilder::make()->withCookieJar(); + + $transport = $builder->build(); + + expect($transport)->toBeInstanceOf(Transport::class); + // Should create a new CookieJar internally + }); + + it('can enable cookies with shortcut method', function () { + $builder = TransportBuilder::make()->withCookies(); + + $transport = $builder->build(); + + expect($transport)->toBeInstanceOf(Transport::class); + // Should create a new CookieJar internally via withCookieJar() + }); + + it('handles cookies automatically when enabled', function () { + $client = Mockery::mock(ClientInterface::class); + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + // After receiving Set-Cookie, the next request should include it + return true; + })) + ->andReturn(new GuzzleResponse(200, [ + 'Set-Cookie' => 'session_id=abc123; Path=/', + ])); + + $client->shouldReceive('sendRequest') + ->once() + ->with(Mockery::on(function ($request) { + // The second request should have the cookie + return $request->getHeaderLine('Cookie') === 'session_id=abc123'; + })) + ->andReturn(new GuzzleResponse(200)); + + $transport = TransportBuilder::make() + ->setClient($client) + ->withCookies() + ->withoutDefaultMiddlewares() + ->build(); + + // First request - receives cookie + $request1 = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/login'); + $transport->sendRequest($request1); + + // Second request - should send cookie + $request2 = new \GuzzleHttp\Psr7\Request('GET', 'https://example.com/api'); + $transport->sendRequest($request2); + }); }); describe('Transport', function () {