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