diff --git a/composer.json b/composer.json
index b92a0b7e..8b68a6a6 100644
--- a/composer.json
+++ b/composer.json
@@ -75,7 +75,8 @@
"composer run-example:protobuf-sync-message",
"composer run-example:stub-server",
"composer run-example:xml",
- "composer run-example:graphql"
+ "composer run-example:graphql",
+ "composer run-example:form-urlencoded"
],
"run-example:binary": [
"rm -f example/binary/pacts/binaryConsumer-binaryProvider.json",
@@ -136,17 +137,22 @@
"rm -f example/graphql/pacts/graphqlConsumer-graphqlProvider.json",
"cd example/graphql/consumer && phpunit",
"cd example/graphql/provider && phpunit"
+ ],
+ "run-example:form-urlencoded": [
+ "rm -f example/graphql/pacts/formUrlEncodedConsumer-formUrlEncodedProvider.json",
+ "cd example/form-urlencoded/consumer && phpunit",
+ "cd example/form-urlencoded/provider && phpunit"
]
},
"extra": {
"downloads": {
"pact-ffi-headers": {
- "version": "0.4.23",
+ "version": "0.4.26",
"url": "https://github.com/pact-foundation/pact-reference/releases/download/libpact_ffi-v{$version}/pact.h",
"path": "bin/pact-ffi-headers/pact.h"
},
"pact-ffi-lib": {
- "version": "0.4.23",
+ "version": "0.4.26",
"variables": {
"{$prefix}": "PHP_OS_FAMILY === 'Windows' ? 'pact_ffi' : 'libpact_ffi'",
"{$os}": "PHP_OS === 'Darwin' ? 'macos' : strtolower(PHP_OS_FAMILY)",
diff --git a/example/form-urlencoded/consumer/autoload.php b/example/form-urlencoded/consumer/autoload.php
new file mode 100644
index 00000000..be68e5c8
--- /dev/null
+++ b/example/form-urlencoded/consumer/autoload.php
@@ -0,0 +1,5 @@
+addPsr4('FormUrlEncodedConsumer\\', __DIR__ . '/src');
+$loader->addPsr4('FormUrlEncodedConsumer\\Tests\\', __DIR__ . '/tests');
diff --git a/example/form-urlencoded/consumer/phpunit.xml b/example/form-urlencoded/consumer/phpunit.xml
new file mode 100644
index 00000000..7768334b
--- /dev/null
+++ b/example/form-urlencoded/consumer/phpunit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ ./tests
+
+
+
+
+
+
diff --git a/example/form-urlencoded/consumer/src/Service/HttpClientService.php b/example/form-urlencoded/consumer/src/Service/HttpClientService.php
new file mode 100644
index 00000000..6c02e51d
--- /dev/null
+++ b/example/form-urlencoded/consumer/src/Service/HttpClientService.php
@@ -0,0 +1,50 @@
+httpClient = new Client();
+ $this->baseUri = $baseUri;
+ }
+
+ public function createUser(): string
+ {
+ $response = $this->httpClient->post(new Uri("{$this->baseUri}/users"), [
+ 'body' => http_build_query([
+ 'empty' => '',
+ 'agree' => 'true',
+ 'fullname' => 'First Last Name',
+ 'email' => 'user@example.test',
+ 'password' => 'very@secure&password123',
+ 'age' => 41,
+ 'ampersand' => '&',
+ 'slash' => '/',
+ 'question-mark' => '?',
+ 'equals-sign' => '=',
+ '&' => 'ampersand',
+ '/' => 'slash',
+ '?' => 'question-mark',
+ '=' => 'equals-sign',
+ ]) .
+ '&=first&=second&=third' .
+ '&roles[]=User&roles[]=Manager' .
+ '&orders[]=&orders[]=ASC&orders[]=DESC',
+ 'headers' => [
+ 'Accept' => 'application/x-www-form-urlencoded',
+ 'Content-Type' => 'application/x-www-form-urlencoded',
+ ],
+ ]);
+
+ return $response->getBody();
+ }
+}
diff --git a/example/form-urlencoded/consumer/tests/Service/HttpClientServiceTest.php b/example/form-urlencoded/consumer/tests/Service/HttpClientServiceTest.php
new file mode 100644
index 00000000..88c5fc1f
--- /dev/null
+++ b/example/form-urlencoded/consumer/tests/Service/HttpClientServiceTest.php
@@ -0,0 +1,116 @@
+setMethod('POST')
+ ->setPath('/users')
+ ->addHeader('Content-Type', 'application/x-www-form-urlencoded')
+ ->addHeader('Accept', 'application/x-www-form-urlencoded')
+ ->setBody(
+ new Text(
+ json_encode([
+ 'empty' => $matcher->equal(''),
+ 'agree' => $matcher->regex('false', 'true|false'),
+ 'fullname' => $matcher->string('User name'),
+ 'email' => $matcher->email('user@email.test'),
+ 'password' => $matcher->regex('user@password111', '^[\w\d@$!%*#?&^_-]{8,}$'),
+ 'age' => $matcher->number(27),
+ 'roles[]' => $matcher->eachValue(['User'], [$matcher->regex('User', 'Admin|User|Manager')]),
+ 'orders[]' => $matcher->arrayContaining([
+ $matcher->equal('DESC'),
+ $matcher->equal('ASC'),
+ $matcher->equal(''),
+ ]),
+ // Empty string keys are supported
+ '' => ['first', 'second', 'third'],
+ // Null, boolean and object values are not supported, so the values and matchers will be ignored
+ 'null' => $matcher->nullValue(),
+ 'boolean' => $matcher->booleanV3(true),
+ 'object' => $matcher->like([
+ 'key' => $matcher->string('value')
+ ]),
+ // special characters are encoded
+ 'ampersand' => $matcher->equal('&'),
+ 'slash' => '/',
+ 'question-mark' => '?',
+ 'equals-sign' => '=',
+ '&' => 'ampersand',
+ '/' => 'slash',
+ '?' => 'question-mark',
+ '=' => 'equals-sign',
+ ]),
+ 'application/x-www-form-urlencoded'
+ )
+ )
+ ;
+
+ $response = new ProviderResponse();
+ $response
+ ->setStatus(201)
+ ->addHeader('Content-Type', 'application/x-www-form-urlencoded')
+ ->setBody(
+ new Text(
+ json_encode([
+ 'id' => $matcher->uuid(),
+ 'age' => $matcher->integerV3()->withGenerator(new RandomInt(0, 130)),
+ 'name[]' => [
+ $matcher->regex(null, $gender = 'Mr\.|Mrs\.|Miss|Ms\.'),
+ $matcher->string(),
+ $matcher->string(),
+ $matcher->string(),
+ ],
+ ]),
+ 'application/x-www-form-urlencoded'
+ )
+ );
+
+ $config = new MockServerConfig();
+ $config
+ ->setConsumer('formUrlEncodedConsumer')
+ ->setProvider('formUrlEncodedProvider')
+ ->setPactDir(__DIR__.'/../../../pacts');
+ if ($logLevel = \getenv('PACT_LOGLEVEL')) {
+ $config->setLogLevel($logLevel);
+ }
+ $builder = new InteractionBuilder($config);
+ $builder
+ ->given('Endpoint is protected')
+ ->uponReceiving('A post request to /users')
+ ->with($request)
+ ->willRespondWith($response);
+
+ $service = new HttpClientService($config->getBaseUri());
+ parse_str($service->createUser(), $params);
+ $verifyResult = $builder->verify();
+
+ $this->assertTrue(condition: $verifyResult);
+ $this->assertArrayHasKey('id', $params);
+ $pattern = Matcher::UUID_V4_FORMAT;
+ $this->assertMatchesRegularExpression("/{$pattern}/", $params['id']);
+ $this->assertArrayHasKey('age', $params);
+ $this->assertLessThanOrEqual(130, $params['age']);
+ $this->assertGreaterThanOrEqual(0, $params['age']);
+ $this->assertArrayHasKey('name', $params);
+ $this->assertIsArray($params['name']);
+ $this->assertCount(4, $params['name']);
+ $this->assertMatchesRegularExpression("/{$gender}/", $params['name'][0]);
+ }
+}
diff --git a/example/form-urlencoded/pacts/formUrlEncodedConsumer-formUrlEncodedProvider.json b/example/form-urlencoded/pacts/formUrlEncodedConsumer-formUrlEncodedProvider.json
new file mode 100644
index 00000000..588daca5
--- /dev/null
+++ b/example/form-urlencoded/pacts/formUrlEncodedConsumer-formUrlEncodedProvider.json
@@ -0,0 +1,253 @@
+{
+ "consumer": {
+ "name": "formUrlEncodedConsumer"
+ },
+ "interactions": [
+ {
+ "description": "A post request to /users",
+ "providerStates": [
+ {
+ "name": "Endpoint is protected"
+ }
+ ],
+ "request": {
+ "body": "=first&=second&=third&%26=ampersand&%2F=slash&%3D=equals-sign&%3F=question-mark&age=27&agree=false&ersand=%26&email=user%40email.test&empty=&equals-sign=%3D&fullname=User+name&orders%5B%5D=DESC&orders%5B%5D=ASC&orders%5B%5D=&password=user%40password111&question-mark=%3F&roles%5B%5D=User&slash=%2F",
+ "headers": {
+ "Accept": "application/x-www-form-urlencoded",
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+ "matchingRules": {
+ "body": {
+ "$.age": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "number"
+ }
+ ]
+ },
+ "$.agree": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": "true|false"
+ }
+ ]
+ },
+ "$.ampersand": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "equality"
+ }
+ ]
+ },
+ "$.email": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$"
+ }
+ ]
+ },
+ "$.empty": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "equality"
+ }
+ ]
+ },
+ "$.fullname": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ },
+ "$.password": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": "^[\\w\\d@$!%*#?&^_-]{8,}$"
+ }
+ ]
+ },
+ "$['orders[]']": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "arrayContains",
+ "variants": [
+ {
+ "index": 0,
+ "rules": {
+ "$": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "equality"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "index": 1,
+ "rules": {
+ "$": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "equality"
+ }
+ ]
+ }
+ }
+ },
+ {
+ "index": 2,
+ "rules": {
+ "$": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "equality"
+ }
+ ]
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "$['roles[]']": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "eachValue",
+ "rules": [
+ {
+ "match": "regex",
+ "regex": "Admin|User|Manager"
+ }
+ ],
+ "value": "[\"User\"]"
+ }
+ ]
+ }
+ }
+ },
+ "method": "POST",
+ "path": "/users"
+ },
+ "response": {
+ "body": "age=13&id=&name%5B%5D=&name%5B%5D=&name%5B%5D=&name%5B%5D=",
+ "generators": {
+ "body": {
+ "$.age": {
+ "max": 130,
+ "min": 0,
+ "type": "RandomInt"
+ },
+ "$.id": {
+ "type": "Uuid"
+ },
+ "$['name[]'][0]": {
+ "regex": "Mr\\.|Mrs\\.|Miss|Ms\\.",
+ "type": "Regex"
+ },
+ "$['name[]'][1]": {
+ "size": 10,
+ "type": "RandomString"
+ },
+ "$['name[]'][2]": {
+ "size": 10,
+ "type": "RandomString"
+ },
+ "$['name[]'][3]": {
+ "size": 10,
+ "type": "RandomString"
+ }
+ }
+ },
+ "headers": {
+ "Content-Type": "application/x-www-form-urlencoded"
+ },
+ "matchingRules": {
+ "body": {
+ "$.age": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "integer"
+ }
+ ]
+ },
+ "$.id": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": "^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$"
+ }
+ ]
+ },
+ "$['name[]'][0]": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "regex",
+ "regex": "Mr\\.|Mrs\\.|Miss|Ms\\."
+ }
+ ]
+ },
+ "$['name[]'][1]": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ },
+ "$['name[]'][2]": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ },
+ "$['name[]'][3]": {
+ "combine": "AND",
+ "matchers": [
+ {
+ "match": "type"
+ }
+ ]
+ }
+ }
+ },
+ "status": 201
+ }
+ }
+ ],
+ "metadata": {
+ "pactRust": {
+ "ffi": "0.4.25",
+ "mockserver": "1.2.10",
+ "models": "1.2.6"
+ },
+ "pactSpecification": {
+ "version": "3.0.0"
+ }
+ },
+ "provider": {
+ "name": "formUrlEncodedProvider"
+ }
+}
\ No newline at end of file
diff --git a/example/form-urlencoded/provider/autoload.php b/example/form-urlencoded/provider/autoload.php
new file mode 100644
index 00000000..73bea92f
--- /dev/null
+++ b/example/form-urlencoded/provider/autoload.php
@@ -0,0 +1,4 @@
+addPsr4('FormUrlEncodedProvider\\Tests\\', __DIR__ . '/tests');
diff --git a/example/form-urlencoded/provider/phpunit.xml b/example/form-urlencoded/provider/phpunit.xml
new file mode 100644
index 00000000..7768334b
--- /dev/null
+++ b/example/form-urlencoded/provider/phpunit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ ./tests
+
+
+
+
+
+
diff --git a/example/form-urlencoded/provider/public/index.php b/example/form-urlencoded/provider/public/index.php
new file mode 100644
index 00000000..1e91e9fe
--- /dev/null
+++ b/example/form-urlencoded/provider/public/index.php
@@ -0,0 +1,39 @@
+post('/users', function (ServerRequestInterface $request) {
+ $response = new Response();
+ $auth = $request->getHeaderLine('Authorization');
+ if ($auth != 'Bearer 1a2b3c4d5e6f7g8h9i0k') {
+ return $response->withStatus(403);
+ }
+
+ error_log(sprintf('request body: %s', (string) $request->getBody()));
+
+ $response->getBody()->write(
+ 'id=49dcfd3f-a5c9-49cb-a09e-a40a1da936b9' .
+ '&age=73' .
+ '&name[]=Mr.' .
+ '&name[]=First' .
+ '&name[]=Middle' .
+ '&name[]=Last'
+ // @todo Follow Postel's law
+ // '&extra=any',
+ );
+
+ return $response
+ ->withStatus(201)
+ ->withHeader('Content-Type', 'application/x-www-form-urlencoded');
+});
+
+$app->post('/pact-change-state', function (ServerRequestInterface $request) {
+ return new Response();
+});
+
+$app->run();
diff --git a/example/form-urlencoded/provider/tests/PactVerifyTest.php b/example/form-urlencoded/provider/tests/PactVerifyTest.php
new file mode 100644
index 00000000..23971590
--- /dev/null
+++ b/example/form-urlencoded/provider/tests/PactVerifyTest.php
@@ -0,0 +1,52 @@
+process = new PhpProcess(__DIR__ . '/../public/');
+ $this->process->start();
+ }
+
+ 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('formUrlEncodedProvider') // Providers name to fetch.
+ ->setHost('localhost')
+ ->setPort($this->process->getPort());
+ $config->getProviderState()
+ ->setStateChangeUrl(new Uri(sprintf('http://localhost:%d/pact-change-state', $this->process->getPort())))
+ ;
+ $config->getCustomHeaders()
+ ->addHeader('Authorization', 'Bearer 1a2b3c4d5e6f7g8h9i0k');
+ if ($level = \getenv('PACT_LOGLEVEL')) {
+ $config->setLogLevel($level);
+ }
+
+ $verifier = new Verifier($config);
+ $verifier->addFile(__DIR__ . '/../../pacts/formUrlEncodedConsumer-formUrlEncodedProvider.json');
+
+ $verifyResult = $verifier->verify();
+
+ $this->assertTrue($verifyResult);
+ }
+}