diff --git a/composer.json b/composer.json index e558f038..2f9e524c 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,11 @@ "BinaryConsumer\\": "example/binary/consumer/src", "BinaryConsumer\\Tests\\": "example/binary/consumer/tests", "BinaryProvider\\": "example/binary/provider/src", - "BinaryProvider\\Tests\\": "example/binary/provider/tests" + "BinaryProvider\\Tests\\": "example/binary/provider/tests", + "MultipartConsumer\\": "example/multipart/consumer/src", + "MultipartConsumer\\Tests\\": "example/multipart/consumer/tests", + "MultipartProvider\\": "example/multipart/provider/src", + "MultipartProvider\\Tests\\": "example/multipart/provider/tests" } }, "scripts": { diff --git a/example/multipart/consumer/phpunit.xml b/example/multipart/consumer/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/multipart/consumer/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/multipart/consumer/src/Service/HttpClientService.php b/example/multipart/consumer/src/Service/HttpClientService.php new file mode 100644 index 00000000..9e59282f --- /dev/null +++ b/example/multipart/consumer/src/Service/HttpClientService.php @@ -0,0 +1,52 @@ +httpClient = new Client(); + $this->baseUri = $baseUri; + } + + public function updateUserProfile(): string + { + $response = $this->httpClient->post("{$this->baseUri}/user-profile", [ + 'multipart' => [ + [ + 'name' => 'full_name', + 'contents' => 'Zoey Turcotte' + ], + [ + 'name' => 'profile_image', + 'contents' => file_get_contents(__DIR__ . '/../_resource/image.jpg') + ], + [ + 'name' => 'personal_note', + 'contents' => 'testing', + 'filename' => 'note.txt', + 'headers' => [ + 'X-Foo' => 'this is a note', + 'Content-Type' => 'application/octet-stream', + ], + ], + ], + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ZmluLWFwaTphcGktc2VjcmV0', + ], + ]); + + return $response->getBody(); + } +} diff --git a/example/multipart/consumer/src/_resource/image.jpg b/example/multipart/consumer/src/_resource/image.jpg new file mode 100644 index 00000000..76cec75c Binary files /dev/null and b/example/multipart/consumer/src/_resource/image.jpg differ diff --git a/example/multipart/consumer/tests/Service/HttpClientServiceTest.php b/example/multipart/consumer/tests/Service/HttpClientServiceTest.php new file mode 100644 index 00000000..eeaebc11 --- /dev/null +++ b/example/multipart/consumer/tests/Service/HttpClientServiceTest.php @@ -0,0 +1,95 @@ +setMethod('POST') + ->setPath('/user-profile') + ->setHeaders([ + 'Accept' => 'application/json', + 'Authorization' => [ + \json_encode($matcher->like('Bearer eyJhbGciOiJIUzI1NiIXVCJ9')) + ], + ]) + ->setBody(new Multipart([ + new Part($fullNameTempFile = $this->createTempFile($fullName), 'full_name', 'text/plain'), + new Part(__DIR__ . '/../_resource/image.jpg', 'profile_image', 'image/jpeg'), + new Part($personalNoteTempFile = $this->createTempFile($personalNote), 'personal_note', 'text/plain'), + ])); + + $response = new ProviderResponse(); + $response + ->setStatus(200) + ->addHeader('Content-Type', 'application/json') + ->setBody([ + 'full_name' => $matcher->like($fullName), + 'profile_image' => $matcher->regex($profileImageUrl, self::URL_FORMAT), + 'personal_note' => $matcher->like($personalNote), + ]); + + $config = new MockServerConfig(); + $config + ->setConsumer('multipartConsumer') + ->setProvider('multipartProvider') + ->setPactDir(__DIR__.'/../../../pacts'); + if ($logLevel = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($logLevel); + } + $builder = new InteractionBuilder($config); + $builder + ->given('User exists') + ->uponReceiving('A put request to /user-profile') + ->with($request) + ->willRespondWith($response); + + $service = new HttpClientService($config->getBaseUri()); + $userProfileResponse = $service->updateUserProfile(); + $verifyResult = $builder->verify(); + + unlink($fullNameTempFile); + unlink($personalNoteTempFile); + + $this->assertTrue($verifyResult); + $this->assertEquals([ + 'full_name' => $fullName, + 'profile_image' => $profileImageUrl, + 'personal_note' => $personalNote, + ], \json_decode($userProfileResponse, true, 512, JSON_THROW_ON_ERROR)); + } + + private function createTempFile(string $contents): string + { + $path = tempnam(sys_get_temp_dir(), 'pact'); + //$newPath = "$path.txt"; + //rename($path, $newPath); + $newPath = $path; + + $handle = fopen($newPath, 'w'); + fwrite($handle, $contents); + fclose($handle); + + return $newPath; + } +} diff --git a/example/multipart/consumer/tests/_resource/image.jpg b/example/multipart/consumer/tests/_resource/image.jpg new file mode 100644 index 00000000..82b33626 Binary files /dev/null and b/example/multipart/consumer/tests/_resource/image.jpg differ diff --git a/example/multipart/pacts/multipartConsumer-multipartProvider.json b/example/multipart/pacts/multipartConsumer-multipartProvider.json new file mode 100644 index 00000000..8b7f10ab --- /dev/null +++ b/example/multipart/pacts/multipartConsumer-multipartProvider.json @@ -0,0 +1,137 @@ +{ + "consumer": { + "name": "multipartConsumer" + }, + "interactions": [ + { + "description": "A put request to /user-profile", + "providerStates": [ + { + "name": "User exists" + } + ], + "request": { + "body": "--MxafNCX4RAVZ1d2c\r\nContent-Disposition: form-data; name=\"personal_note\"; filename=\"pactmk99lD\"\r\nContent-Type: application/octet-stream\r\n\r\ntesting\r\n--MxafNCX4RAVZ1d2c--\r\n", + "headers": { + "Accept": "application/json", + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIXVCJ9", + "Content-Type": "multipart/form-data; boundary=MxafNCX4RAVZ1d2c" + }, + "matchingRules": { + "body": { + "$.full_name": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "text/plain" + } + ] + }, + "$.personal_note": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "text/plain" + } + ] + }, + "$.profile_image": { + "combine": "AND", + "matchers": [ + { + "match": "contentType", + "value": "image/jpeg" + } + ] + } + }, + "header": { + "$.Authorization[0]": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "Content-Type": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "multipart/form-data;(\\s*charset=[^;]*;)?\\s*boundary=.*" + }, + { + "match": "regex", + "regex": "multipart/form-data;(\\s*charset=[^;]*;)?\\s*boundary=.*" + }, + { + "match": "regex", + "regex": "multipart/form-data;(\\s*charset=[^;]*;)?\\s*boundary=.*" + } + ] + } + } + }, + "method": "POST", + "path": "/user-profile" + }, + "response": { + "body": { + "full_name": "Colten Ziemann", + "personal_note": "testing", + "profile_image": "http://example.test/profile-image.jpg" + }, + "headers": { + "Content-Type": "application/json" + }, + "matchingRules": { + "body": { + "$.full_name": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.personal_note": { + "combine": "AND", + "matchers": [ + { + "match": "type" + } + ] + }, + "$.profile_image": { + "combine": "AND", + "matchers": [ + { + "match": "regex", + "regex": "^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()!@:%_\\+.~#?&\\/\\/=]*)" + } + ] + } + }, + "header": {} + }, + "status": 200 + } + } + ], + "metadata": { + "pactRust": { + "ffi": "0.4.7", + "mockserver": "1.2.3", + "models": "1.1.9" + }, + "pactSpecification": { + "version": "3.0.0" + } + }, + "provider": { + "name": "multipartProvider" + } +} \ No newline at end of file diff --git a/example/multipart/provider/phpunit.xml b/example/multipart/provider/phpunit.xml new file mode 100644 index 00000000..62a9eb00 --- /dev/null +++ b/example/multipart/provider/phpunit.xml @@ -0,0 +1,11 @@ + + + + + ./tests + + + + + + diff --git a/example/multipart/provider/public/index.php b/example/multipart/provider/public/index.php new file mode 100644 index 00000000..6111dfc8 --- /dev/null +++ b/example/multipart/provider/public/index.php @@ -0,0 +1,23 @@ +addBodyParsingMiddleware(); + +$app->post('/user-profile', function (Request $request, Response $response) { + $fileName = (string)$request->getUploadedFiles()['profile_image']->getClientFilename(); + $response->getBody()->write(\json_encode([ + 'full_name' => (string)$request->getUploadedFiles()['full_name']->getStream(), + 'profile_image' => "http://example.test/$fileName", + 'personal_note' => (string)$request->getUploadedFiles()['personal_note']->getStream(), + ])); + + return $response->withHeader('Content-Type', 'application/json'); +}); + +$app->run(); diff --git a/example/multipart/provider/tests/PactVerifyTest.php b/example/multipart/provider/tests/PactVerifyTest.php new file mode 100644 index 00000000..e4fee86b --- /dev/null +++ b/example/multipart/provider/tests/PactVerifyTest.php @@ -0,0 +1,62 @@ +process = new Process(['php', '-S', '127.0.0.1:7202', '-t', __DIR__ . '/../public/']); + + $this->process->start(); + $this->process->waitUntil(function (): bool { + $fp = @fsockopen('127.0.0.1', 7202); + $isOpen = is_resource($fp); + if ($isOpen) { + fclose($fp); + } + + return $isOpen; + }); + } + + /** + * Stop the web server process once complete. + */ + protected function tearDown(): void + { + $this->process->stop(); + } + + /** + * This test will run after the web server is started. + */ + public function testPactVerifyConsumer() + { + $config = new VerifierConfig(); + $config->getProviderInfo() + ->setName('multipartProvider') // Providers name to fetch. + ->setHost('localhost') + ->setPort(7202); + if ($level = \getenv('PACT_LOGLEVEL')) { + $config->setLogLevel($level); + } + + $verifier = new Verifier($config); + $verifier->addFile(__DIR__ . '/../../pacts/multipartConsumer-multipartProvider.json'); + + $verifyResult = $verifier->verify(); + + $this->assertTrue($verifyResult); + } +} diff --git a/phpunit.xml b/phpunit.xml index 3c2344a0..f82b56e4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -25,6 +25,12 @@ ./example/binary/provider/tests + + ./example/multipart/consumer/tests + + + ./example/multipart/provider/tests + ./example/message/consumer/tests diff --git a/src/PhpPact/Consumer/Exception/BodyNotSupportedException.php b/src/PhpPact/Consumer/Exception/BodyNotSupportedException.php new file mode 100644 index 00000000..f2c767bf --- /dev/null +++ b/src/PhpPact/Consumer/Exception/BodyNotSupportedException.php @@ -0,0 +1,9 @@ + $parts + */ + public function __construct(private array $parts) + { + $this->setParts($parts); + } + + /** + * @return array + */ + public function getParts(): array + { + return $this->parts; + } + + /** + * @param array $parts + */ + public function setParts(array $parts): self + { + $this->parts = []; + foreach ($parts as $part) { + $this->addPart($part); + } + + return $this; + } + + public function addPart(Part $part): self + { + $this->parts[] = $part; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Body/Part.php b/src/PhpPact/Consumer/Model/Body/Part.php new file mode 100644 index 00000000..29241242 --- /dev/null +++ b/src/PhpPact/Consumer/Model/Body/Part.php @@ -0,0 +1,42 @@ +setContentType($contentType); + } + + public function getPath(): string + { + return $this->path; + } + + public function setPath(string $path): self + { + $this->path = $path; + + return $this; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php b/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php index 3cb31e7f..1481c921 100644 --- a/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php +++ b/src/PhpPact/Consumer/Model/Interaction/BodyTrait.php @@ -4,13 +4,14 @@ use JsonException; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; trait BodyTrait { - private Text|Binary|null $body = null; + private Text|Binary|Multipart|null $body = null; - public function getBody(): Text|Binary|null + public function getBody(): Text|Binary|Multipart|null { return $this->body; } @@ -22,7 +23,7 @@ public function setBody(mixed $body): self { if (\is_string($body)) { $this->body = new Text($body, 'text/plain'); - } elseif (\is_null($body) || $body instanceof Text || $body instanceof Binary) { + } elseif (\is_null($body) || $body instanceof Text || $body instanceof Binary || $body instanceof Multipart) { $this->body = $body; } else { $this->body = new Text(\json_encode($body, JSON_THROW_ON_ERROR), 'application/json'); diff --git a/src/PhpPact/Consumer/Model/Message.php b/src/PhpPact/Consumer/Model/Message.php index 8d2d2a4e..90ba3356 100644 --- a/src/PhpPact/Consumer/Model/Message.php +++ b/src/PhpPact/Consumer/Model/Message.php @@ -3,7 +3,9 @@ namespace PhpPact\Consumer\Model; use JsonException; +use PhpPact\Consumer\Exception\BodyNotSupportedException; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; /** @@ -74,6 +76,8 @@ public function setContents(mixed $contents): self $this->contents = new Text($contents, 'text/plain'); } elseif (\is_null($contents) || $contents instanceof Text || $contents instanceof Binary) { $this->contents = $contents; + } elseif ($contents instanceof Multipart) { + throw new BodyNotSupportedException('Message does not support multipart'); } else { $this->contents = new Text(\json_encode($contents, JSON_THROW_ON_ERROR), 'application/json'); } diff --git a/src/PhpPact/Consumer/Registry/Interaction/Body/AbstractBodyRegistry.php b/src/PhpPact/Consumer/Registry/Interaction/Body/AbstractBodyRegistry.php index aa3760d9..bd82093e 100644 --- a/src/PhpPact/Consumer/Registry/Interaction/Body/AbstractBodyRegistry.php +++ b/src/PhpPact/Consumer/Registry/Interaction/Body/AbstractBodyRegistry.php @@ -2,8 +2,13 @@ namespace PhpPact\Consumer\Registry\Interaction\Body; +use FFI; +use FFI\CData; use PhpPact\Consumer\Exception\InteractionBodyNotAddedException; +use PhpPact\Consumer\Exception\PartNotAddedException; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; +use PhpPact\Consumer\Model\Body\Part; use PhpPact\Consumer\Model\Body\Text; use PhpPact\Consumer\Registry\Interaction\InteractionRegistryInterface; use PhpPact\FFI\ClientInterface; @@ -16,13 +21,25 @@ public function __construct( ) { } - public function withBody(Text|Binary $body): void + public function withBody(Text|Binary|Multipart $body): void { - if ($body instanceof Binary) { - $success = $this->client->call('pactffi_with_binary_file', $this->interactionRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()->getValue(), $body->getContents()->getSize()); - } else { - $success = $this->client->call('pactffi_with_body', $this->interactionRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()); - } + $success = match ($body::class) { + Binary::class => $this->client->call('pactffi_with_binary_file', $this->interactionRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()->getValue(), $body->getContents()->getSize()), + Text::class => $this->client->call('pactffi_with_body', $this->interactionRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()), + Multipart::class => array_reduce( + $body->getParts(), + function (bool $success, Part $part) { + $result = $this->client->call('pactffi_with_multipart_file', $this->interactionRegistry->getId(), $this->getPart(), $part->getContentType(), $part->getPath(), $part->getName()); + if ($result->failed instanceof CData) { + throw new PartNotAddedException(FFI::string($result->failed)); + } + + return true; + }, + true + ), + default => false, + }; if (!$success) { throw new InteractionBodyNotAddedException(); } diff --git a/src/PhpPact/Consumer/Registry/Interaction/Body/BodyRegistryInterface.php b/src/PhpPact/Consumer/Registry/Interaction/Body/BodyRegistryInterface.php index d7f69d3e..d1f668b9 100644 --- a/src/PhpPact/Consumer/Registry/Interaction/Body/BodyRegistryInterface.php +++ b/src/PhpPact/Consumer/Registry/Interaction/Body/BodyRegistryInterface.php @@ -3,9 +3,10 @@ namespace PhpPact\Consumer\Registry\Interaction\Body; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; interface BodyRegistryInterface { - public function withBody(Text|Binary $body): void; + public function withBody(Text|Binary|Multipart $body): void; } diff --git a/src/PhpPact/Consumer/Registry/Interaction/Body/MessageContentsRegistry.php b/src/PhpPact/Consumer/Registry/Interaction/Body/MessageContentsRegistry.php index 251a7088..f352eb88 100644 --- a/src/PhpPact/Consumer/Registry/Interaction/Body/MessageContentsRegistry.php +++ b/src/PhpPact/Consumer/Registry/Interaction/Body/MessageContentsRegistry.php @@ -2,8 +2,10 @@ namespace PhpPact\Consumer\Registry\Interaction\Body; +use PhpPact\Consumer\Exception\BodyNotSupportedException; use PhpPact\Consumer\Exception\MessageContentsNotAddedException; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; use PhpPact\Consumer\Registry\Interaction\MessageRegistryInterface; use PhpPact\Consumer\Registry\Interaction\Part\RequestPartTrait; @@ -19,13 +21,14 @@ public function __construct( ) { } - public function withBody(Text|Binary $body): void + public function withBody(Text|Binary|Multipart $body): void { - if ($body instanceof Binary) { - $success = $this->client->call('pactffi_with_binary_file', $this->messageRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()->getValue(), $body->getContents()->getSize()); - } else { - $success = $this->client->call('pactffi_with_body', $this->messageRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()); - } + $success = match ($body::class) { + Binary::class => $this->client->call('pactffi_with_binary_file', $this->messageRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()->getValue(), $body->getContents()->getSize()), + Text::class => $this->client->call('pactffi_with_body', $this->messageRegistry->getId(), $this->getPart(), $body->getContentType(), $body->getContents()), + Multipart::class => throw new BodyNotSupportedException('Message does not support multipart'), + default => false, + }; if (!$success) { throw new MessageContentsNotAddedException(); } diff --git a/src/PhpPact/Consumer/Registry/Interaction/Part/AbstractPartRegistry.php b/src/PhpPact/Consumer/Registry/Interaction/Part/AbstractPartRegistry.php index 787d71dc..f9369e45 100644 --- a/src/PhpPact/Consumer/Registry/Interaction/Part/AbstractPartRegistry.php +++ b/src/PhpPact/Consumer/Registry/Interaction/Part/AbstractPartRegistry.php @@ -3,6 +3,7 @@ namespace PhpPact\Consumer\Registry\Interaction\Part; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; use PhpPact\Consumer\Registry\Interaction\Body\BodyRegistryInterface; use PhpPact\Consumer\Registry\Interaction\InteractionRegistryInterface; @@ -17,7 +18,7 @@ public function __construct( ) { } - public function withBody(Text|Binary|null $body): self + public function withBody(Text|Binary|Multipart|null $body): self { if ($body) { $this->bodyRegistry->withBody($body); diff --git a/src/PhpPact/Consumer/Registry/Interaction/Part/PartRegistryInterface.php b/src/PhpPact/Consumer/Registry/Interaction/Part/PartRegistryInterface.php index 5f7f59f8..383e3a41 100644 --- a/src/PhpPact/Consumer/Registry/Interaction/Part/PartRegistryInterface.php +++ b/src/PhpPact/Consumer/Registry/Interaction/Part/PartRegistryInterface.php @@ -3,11 +3,12 @@ namespace PhpPact\Consumer\Registry\Interaction\Part; use PhpPact\Consumer\Model\Body\Binary; +use PhpPact\Consumer\Model\Body\Multipart; use PhpPact\Consumer\Model\Body\Text; interface PartRegistryInterface { - public function withBody(Text|Binary|null $body): self; + public function withBody(Text|Binary|Multipart|null $body): self; /** * @param array $headers