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); + } +}