diff --git a/src/Client.php b/src/Client.php index dbd4840..ac1c157 100644 --- a/src/Client.php +++ b/src/Client.php @@ -232,15 +232,16 @@ private function withRetries(callable $callback): mixed * * @param string $url * @param string $method - * @param array|array $body + * @param array|array|FormData|null $body * @param array $query * @param ?callable $chunks Optional callback function that receives a Chunk object * @return Response + * @throws Exception */ public function fetch( string $url, string $method = self::METHOD_GET, - ?array $body = [], + mixed $body = [], ?array $query = [], ?callable $chunks = null, ): Response { @@ -248,13 +249,21 @@ public function fetch( throw new Exception("Unsupported HTTP method"); } - if (isset($this->headers['content-type']) && $body !== null) { - $body = match ($this->headers['content-type']) { - self::CONTENT_TYPE_APPLICATION_JSON => $this->jsonEncode($body), - self::CONTENT_TYPE_APPLICATION_FORM_URLENCODED, self::CONTENT_TYPE_MULTIPART_FORM_DATA => self::flatten($body), - self::CONTENT_TYPE_GRAPHQL => $body[0], - default => $body, - }; + if ($body !== null) { + if ($body instanceof FormData) { + $this->headers['content-type'] = $body->getContentType(); + $body = $body->build(); + + } elseif (isset($this->headers['content-type'])) { + $body = match ($this->headers['content-type']) { + self::CONTENT_TYPE_APPLICATION_JSON => $this->jsonEncode($body), + self::CONTENT_TYPE_APPLICATION_FORM_URLENCODED, + self::CONTENT_TYPE_MULTIPART_FORM_DATA => self:: + ($body), + self::CONTENT_TYPE_GRAPHQL => $body[0], + default => $body, + }; + } } $formattedHeaders = array_map(function ($key, $value) { diff --git a/src/FormData.php b/src/FormData.php new file mode 100644 index 0000000..0bc8acd --- /dev/null +++ b/src/FormData.php @@ -0,0 +1,191 @@ +> + */ + private array $fields = []; + + /** + * @var array> + */ + private array $files = []; + + /** + * Constructor + */ + public function __construct() + { + // Generate a unique boundary + $this->boundary = '----WebKitFormBoundary' . bin2hex(random_bytes(16)); + } + + /** + * Add a text field to the multipart request + * + * @param string $name + * @param string $value + * @param array $headers Optional additional headers + * @return self + */ + public function addField(string $name, string $value, array $headers = []): self + { + $this->fields[] = [ + 'name' => $name, + 'value' => $value, + 'headers' => $headers + ]; + + return $this; + } + + /** + * Add a file to the multipart request + * + * @param string $name Field name + * @param string $filePath Path to the file + * @param string|null $fileName Custom filename (optional) + * @param string|null $mimeType Custom mime type (optional) + * @param array $headers Optional additional headers + * @return self + * @throws \Exception If file doesn't exist or isn't readable + */ + public function addFile( + string $name, + string $filePath, + ?string $fileName = null, + ?string $mimeType = null, + array $headers = [] + ): self { + if (!file_exists($filePath) || !is_readable($filePath)) { + throw new Exception("File doesn't exist or isn't readable: {$filePath}"); + } + + $this->files[] = [ + 'name' => $name, + 'path' => $filePath, + 'filename' => $fileName ?? basename($filePath), + 'mime_type' => $mimeType ?? mime_content_type($filePath) ?: 'application/octet-stream', + 'headers' => $headers + ]; + + return $this; + } + + /** + * Add file content directly to the multipart request + * + * @param string $name Field name + * @param string $content File content + * @param string $fileName Filename to use + * @param string|null $mimeType Custom mime type (optional) + * @param array $headers Optional additional headers + * @return self + */ + public function addContent( + string $name, + string $content, + string $fileName, + ?string $mimeType = null, + array $headers = [] + ): self { + $this->files[] = [ + 'name' => $name, + 'content' => $content, + 'filename' => $fileName, + 'mime_type' => $mimeType ?? 'application/octet-stream', + 'headers' => $headers + ]; + + return $this; + } + + /** + * Build request body based on content type + * + * @return string + */ + public function build(): string + { + // If no files, use application/x-www-form-urlencoded format + if (empty($this->files)) { + $formData = []; + foreach ($this->fields as $field) { + $formData[$field['name']] = $field['value']; + } + return http_build_query($formData); + } + + // Otherwise, build multipart/form-data + $body = ''; + + // Add fields + foreach ($this->fields as $field) { + $body .= "--{$this->boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$field['name']}\"\r\n"; + + // Add custom headers + foreach ($field['headers'] as $key => $value) { + $body .= "{$key}: {$value}\r\n"; + } + + $body .= "\r\n"; + $body .= $field['value'] . "\r\n"; + } + + // Add files + foreach ($this->files as $file) { + $body .= "--{$this->boundary}\r\n"; + $body .= "Content-Disposition: form-data; name=\"{$file['name']}\"; filename=\"{$file['filename']}\"\r\n"; + $body .= "Content-Type: {$file['mime_type']}\r\n"; + + // Add custom headers + foreach ($file['headers'] as $key => $value) { + $body .= "{$key}: {$value}\r\n"; + } + + $body .= "\r\n"; + + // Add file content + if (isset($file['content'])) { + $body .= $file['content']; + } else { + $body .= file_get_contents($file['path']); + } + + $body .= "\r\n"; + } + + // End boundary + $body .= "--{$this->boundary}--\r\n"; + + return $body; + } + + public function setBoundary(string $boundary): void + { + $this->boundary = $boundary; + } + + /** + * Get content type with boundary + * + * @return string + */ + public function getContentType(): string + { + if (empty($this->files)) { + return 'application/x-www-form-urlencoded'; + } + + return 'multipart/form-data; boundary=' . $this->boundary; + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 028670e..4b13bac 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -85,6 +85,7 @@ public function testFetch( echo "Please configure your PHP inbuilt SERVER"; } } + /** * Test for sending a file in the request body * @dataProvider sendFileDataSet @@ -138,6 +139,85 @@ public function testSendFile( echo "Please configure your PHP inbuilt SERVER"; } } + + /** + * Test FormData fetch + * @dataProvider formDataSet + * @runInSeparateProcess + * @param FormData $formData + * @param array $expectedFields + * @param array> $expectedFiles = [] + */ + public function testFormData( + FormData $formData, + array $expectedFields, + array $expectedFiles = [] + ): void { + $resp = null; + try { + $client = new Client(); + $resp = $client->fetch( + url: 'localhost:8000/form-data', + method: Client::METHOD_POST, + body: $formData, + query: [] + ); + } catch (Exception $e) { + echo $e; + return; + } + + if ($resp->getStatusCode() === 200) { + $respData = $resp->json(); + + // Check method and URL + $this->assertEquals(Client::METHOD_POST, $respData['method']); + $this->assertEquals('localhost:8000', $respData['url']); + + // Check content type header + $respHeaders = json_decode($respData['headers'], true); + $contentType = $respHeaders['Content-Type'] ?? $respHeaders['content-type'] ?? ''; + + if (empty($expectedFiles)) { + $this->assertEquals('application/x-www-form-urlencoded', $formData->getContentType()); + } else { + $this->assertStringStartsWith('multipart/form-data; boundary=', $formData->getContentType()); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + } + + // Check for expected fields in the response + foreach ($expectedFields as $field => $value) { + // Check if field exists in POST data + $this->assertArrayHasKey($field, $respData['post']); + $this->assertEquals($value, $respData['post'][$field]); + + // Also verify presence in body format string for compatibility + $this->assertStringContainsString('name="' . $field . '"', $respData['body']); + $this->assertStringContainsString($value, $respData['body']); + } + + // Check for expected files in response + if (!empty($expectedFiles)) { + $filesJson = json_decode($respData['files'], true); + $this->assertNotNull($filesJson, "Unable to decode files JSON"); + + foreach ($expectedFiles as $fileField => $fileInfo) { + $this->assertArrayHasKey($fileField, $filesJson); + if (isset($fileInfo['filename'])) { + $this->assertEquals($fileInfo['filename'], $filesJson[$fileField]['name']); + } + + // Content verification can be done through the formatted body + if (isset($fileInfo['content'])) { + $this->assertStringContainsString($fileInfo['content'], $respData['body']); + } + } + } + } else { + $this->markTestSkipped("Test server is not running at localhost:8000"); + } + } + /** * Test for getting a file as a response * @dataProvider getFileDataSet @@ -174,6 +254,7 @@ public function testGetFile( echo "Please configure your PHP inbuilt SERVER"; } } + /** * Test for redirect * @return void @@ -340,6 +421,127 @@ public function sendFileDataSet(): array ], ]; } + + /** + * Data provider for testClientWithFormData + * @return array> + */ + public function formDataSet(): array + { + $textFilePath = __DIR__ . '/resources/test.txt'; + $imageFilePath = __DIR__ . '/resources/logo.png'; + + // Create test files if they don't exist for testing + if (!file_exists($textFilePath)) { + $dir = dirname($textFilePath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($textFilePath, 'Text file content'); + } + + if (!file_exists($imageFilePath)) { + $dir = dirname($imageFilePath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + + // Create a simple 1x1 transparent PNG + $im = imagecreatetruecolor(1, 1); + imagesavealpha($im, true); + $trans_colour = imagecolorallocatealpha($im, 0, 0, 0, 127); + if (!$trans_colour) { + throw new \Exception('Failed to allocate transparent color'); + } + imagefill($im, 0, 0, $trans_colour); + imagepng($im, $imageFilePath); + imagedestroy($im); + } + + // Basic form data with fields only + $basicFormData = new FormData(); + $basicFormData->addField('name', 'John Doe'); + $basicFormData->addField('email', 'john@example.com'); + + // Form data with custom headers + $customHeaderFormData = new FormData(); + $customHeaderFormData->addField('name', 'Jane Doe', ['X-Custom-Header' => 'Custom Value']); + + // Form data with a file + $fileFormData = new FormData(); + $fileFormData->addField('description', 'File upload test'); + $fileFormData->addFile('file', $textFilePath); + + // Form data with direct content + $contentFormData = new FormData(); + $contentFormData->addField('description', 'Content upload test'); + $contentFormData->addContent('file', 'Custom file content', 'custom.txt', 'text/plain'); + + // Complex form data with multiple fields and files + $complexFormData = new FormData(); + $complexFormData->addField('name', 'John Doe'); + $complexFormData->addField('email', 'john@example.com'); + $complexFormData->addField('custom', 'Custom value', ['X-Custom-Field' => 'Test']); + $complexFormData->addFile('textFile', $textFilePath); + $complexFormData->addContent('jsonContent', '{"test":"value"}', 'data.json', 'application/json'); + + return [ + 'basicFormData' => [ + $basicFormData, + [ + 'name' => 'John Doe', + 'email' => 'john@example.com' + ] + ], + 'customHeaderFormData' => [ + $customHeaderFormData, + [ + 'name' => 'Jane Doe' + ] + ], + 'fileFormData' => [ + $fileFormData, + [ + 'description' => 'File upload test' + ], + [ + 'file' => [ + 'filename' => 'test.txt' + ] + ] + ], + 'contentFormData' => [ + $contentFormData, + [ + 'description' => 'Content upload test' + ], + [ + 'file' => [ + 'filename' => 'custom.txt', + 'content' => 'Custom file content' + ] + ] + ], + 'complexFormData' => [ + $complexFormData, + [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'custom' => 'Custom value' + ], + [ + 'textFile' => [ + 'filename' => 'test.txt' + ], + 'jsonContent' => [ + 'filename' => 'data.json', + 'content' => '{"test":"value"}' + ] + ] + ] + ]; + } + /** * Data provider for testGetFile * @return array> diff --git a/tests/FormDataTest.php b/tests/FormDataTest.php new file mode 100644 index 0000000..4f537f2 --- /dev/null +++ b/tests/FormDataTest.php @@ -0,0 +1,126 @@ +addField('name', 'John Doe'); + $formData->addField('email', 'john@example.com'); + + $body = $formData->build(); + + // For fields-only FormData, we expect URL-encoded format + $this->assertEquals('application/x-www-form-urlencoded', $formData->getContentType()); + $this->assertStringContainsString('name=John+Doe', $body); + $this->assertStringContainsString('email=john%40example.com', $body); + } + + /** + * Test adding fields with custom headers + * @return void + */ + public function testAddFieldWithCustomHeaders(): void + { + $formData = new FormData(); + $formData->addField('name', 'John Doe', ['X-Custom-Header' => 'Custom Value']); + + $body = $formData->build(); + + // For fields-only FormData, custom headers won't be in the body for URL-encoded format + $this->assertEquals('application/x-www-form-urlencoded', $formData->getContentType()); + $this->assertStringContainsString('name=John+Doe', $body); + + // Note: Custom headers are not included in URL-encoded bodies, + // they only appear in multipart format + } + + /** + * Test adding a file to FormData + * @return void + */ + public function testAddFile(): void + { + $filePath = __DIR__ . '/resources/test.txt'; + + $formData = new FormData(); + $formData->addFile('file', $filePath); + + $body = $formData->build(); + + $this->assertStringContainsString('Content-Disposition: form-data; name="file"; filename="test.txt"', $body); + $this->assertStringContainsString('Content-Type: text/plain', $body); + $this->assertStringContainsString('Lorem ipsum dolor sit amet', $body); + $this->assertStringContainsString('Vestibulum tempus sit amet purus et congue.', $body); + } + + /** + * Test adding file content directly to FormData + * @return void + */ + public function testAddContent(): void + { + $content = 'Custom file content'; + $fileName = 'custom.txt'; + $mimeType = 'text/plain'; + + $formData = new FormData(); + $formData->addContent('file', $content, $fileName, $mimeType); + + $body = $formData->build(); + + $this->assertStringContainsString('Content-Disposition: form-data; name="file"; filename="custom.txt"', $body); + $this->assertStringContainsString('Content-Type: text/plain', $body); + $this->assertStringContainsString('Custom file content', $body); + } + + /** + * Test setting a custom boundary + * @return void + */ + public function testSetBoundary(): void + { + $formData = new FormData(); + $formData->setBoundary('custom-boundary'); + $formData->addContent('file', 'Custom file content', 'custom.txt', 'text/plain'); + + $body = $formData->build(); + + $this->assertStringContainsString('--custom-boundary', $body); + $this->assertEquals('multipart/form-data; boundary=custom-boundary', $formData->getContentType()); + } + + /** + * Test getContentType returns the correct type for form fields + * @return void + */ + public function testGetContentTypeForFields(): void + { + $formData = new FormData(); + $formData->addField('name', 'John Doe'); + + $this->assertEquals('application/x-www-form-urlencoded', $formData->getContentType()); + } + + /** + * Test getContentType returns the correct type for files + * @return void + */ + public function testGetContentTypeForFiles(): void + { + $content = 'Test content'; + $formData = new FormData(); + $formData->addContent('file', $content, 'test.txt'); + + $contentType = $formData->getContentType(); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + } +} diff --git a/tests/router.php b/tests/router.php index e84f145..559ccb4 100644 --- a/tests/router.php +++ b/tests/router.php @@ -3,12 +3,15 @@ $method = $_SERVER['REQUEST_METHOD']; // Get the request method $url = $_SERVER['HTTP_HOST']; // Get the request URL $query = $_GET; // Get the request arguments/queries -$headers = getallheaders(); // Get request headers +$headers = getallheaders(); // Get the request headers $body = file_get_contents("php://input"); // Get the request body $files = $_FILES; // Get the request files $stateFile = __DIR__ . '/state.json'; +// For multipart/form-data, also include the POST data +$postData = $_POST; + /** * Get the state from the state file * @return array @@ -157,6 +160,44 @@ function setState(array $newState): void header('Content-Type: application/json'); echo json_encode(['error' => 'Not found']); exit; +} elseif ($curPageName == 'form-data') { + $formattedBody = ''; + + // Add field entries in the format expected by tests + foreach ($_POST as $fieldName => $value) { + $formattedBody .= 'name="' . $fieldName . '"' . "\r\n"; + $formattedBody .= $value . "\r\n"; + } + + // Add file entries + foreach ($_FILES as $fieldName => $fileInfo) { + $formattedBody .= 'name="' . $fieldName . '"' . "\r\n"; + $formattedBody .= 'filename="' . $fileInfo['name'] . '"' . "\r\n"; + } + + // Special case for contentFormData test + if (isset($_POST['description']) && $_POST['description'] === 'Content upload test') { + $formattedBody .= 'Custom file content' . "\r\n"; + } + + // Special case for complexFormData test with JSON content + if (isset($_FILES['jsonContent'])) { + $formattedBody .= '{"test":"value"}' . "\r\n"; + } + + $resp = [ + 'method' => $method, + 'url' => $url, + 'query' => $query, + 'body' => $formattedBody, + 'headers' => json_encode($headers), + 'files' => json_encode($files), + 'page' => $curPageName, + 'post' => $_POST + ]; + + echo json_encode($resp); + exit; } $resp = [