From 72b725ec810e1edc4a3793be50bbc74db40837f5 Mon Sep 17 00:00:00 2001 From: tienvx Date: Mon, 21 Aug 2023 18:28:59 +0700 Subject: [PATCH] feat: Support http request multipart body --- composer.json | 6 +- example/multipart/consumer/phpunit.xml | 11 ++ .../src/Service/HttpClientService.php | 52 +++++++ .../consumer/src/_resource/image.jpg | Bin 0 -> 278 bytes .../tests/Service/HttpClientServiceTest.php | 95 ++++++++++++ .../consumer/tests/_resource/image.jpg | Bin 0 -> 297 bytes .../multipartConsumer-multipartProvider.json | 137 ++++++++++++++++++ example/multipart/provider/phpunit.xml | 11 ++ example/multipart/provider/public/index.php | 23 +++ .../provider/tests/PactVerifyTest.php | 62 ++++++++ phpunit.xml | 6 + .../Exception/BodyNotSupportedException.php | 9 ++ .../Exception/PartNotAddedException.php | 9 ++ .../Exception/PartNotExistException.php | 9 ++ src/PhpPact/Consumer/Model/Body/Multipart.php | 42 ++++++ src/PhpPact/Consumer/Model/Body/Part.php | 42 ++++++ .../Consumer/Model/Interaction/BodyTrait.php | 7 +- src/PhpPact/Consumer/Model/Message.php | 4 + .../Interaction/Body/AbstractBodyRegistry.php | 29 +++- .../Body/BodyRegistryInterface.php | 3 +- .../Body/MessageContentsRegistry.php | 15 +- .../Interaction/Part/AbstractPartRegistry.php | 3 +- .../Part/PartRegistryInterface.php | 3 +- 23 files changed, 559 insertions(+), 19 deletions(-) create mode 100644 example/multipart/consumer/phpunit.xml create mode 100644 example/multipart/consumer/src/Service/HttpClientService.php create mode 100644 example/multipart/consumer/src/_resource/image.jpg create mode 100644 example/multipart/consumer/tests/Service/HttpClientServiceTest.php create mode 100644 example/multipart/consumer/tests/_resource/image.jpg create mode 100644 example/multipart/pacts/multipartConsumer-multipartProvider.json create mode 100644 example/multipart/provider/phpunit.xml create mode 100644 example/multipart/provider/public/index.php create mode 100644 example/multipart/provider/tests/PactVerifyTest.php create mode 100644 src/PhpPact/Consumer/Exception/BodyNotSupportedException.php create mode 100644 src/PhpPact/Consumer/Exception/PartNotAddedException.php create mode 100644 src/PhpPact/Consumer/Exception/PartNotExistException.php create mode 100644 src/PhpPact/Consumer/Model/Body/Multipart.php create mode 100644 src/PhpPact/Consumer/Model/Body/Part.php 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 0000000000000000000000000000000000000000..76cec75c32a4fb29e466be468aee6fc084ed3d3e GIT binary patch literal 278 zcmex=>ukC3pCfH06P@c#e6w5(U478N!c+l@&7Fb4v=1ZhQ0e{YOFet`SM|3 z$FJ2cS*L8)sx~oL2YCkQSRCK?Mbd1`s)?(X-85VCELk}{mdo_K+N%p2*?&Y#{N`V! z_xNMQ`hJPMZIvIU%Nd7H3p%*pQ9-!w?b@5m=V{K01z}s AZ~y=R literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..82b33626e351368322aa8c0ba2594dd4d2d97d41 GIT binary patch literal 297 zcmex=>ukC3pCfH06P@c#eJHSbP!i_yH`Nrf{us8l;lFk{!|^OQHK zdS+`~^nJmbWtuBjGkHxDGj5RxKjN}G;Y5Sc-hKDyCa5i1d1j^juC1}VrX9W*@#I)s R%6$nB7r8nH3z_WyHv!Y + + + + ./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