Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ jobs:
- name: Validate composer.json and composer.lock
run: composer validate

- name: Add optional JSON Payload Contract integration
if: ${{ matrix.php != '8.2' }}
run: composer require --dev --no-update softcreatr/json-payload-contract:^1.0

- name: Install dependencies
run: composer update --prefer-dist --no-progress --no-interaction --no-suggest ${{ matrix.composer-options }}

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"codeception/util-universalframework": "^2.0"
},
"suggest": {
"aws/aws-sdk-php": "For using AWS Auth"
"aws/aws-sdk-php": "For using AWS Auth",
"softcreatr/json-payload-contract": "For validating and normalizing JSON responses against payload contracts"
},
"minimum-stability": "RC",
"autoload": {
Expand Down
89 changes: 89 additions & 0 deletions src/Codeception/Module/REST.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
use JsonSchema\Validator as JsonSchemaValidator;
use JsonSerializable;
use PHPUnit\Framework\Assert;
use SoftCreatR\JsonPayloadContract\Contract;
use SoftCreatR\JsonPayloadContract\Result;
use SoftCreatR\JsonPayloadContract\Violation;
use Symfony\Component\BrowserKit\AbstractBrowser;
use Symfony\Component\HttpKernel\HttpKernelBrowser;

Expand Down Expand Up @@ -1063,6 +1066,92 @@ public function grabDataFromResponseByJsonPath(string $jsonPath): array
return (new JsonArray($this->connectionModule->_getResponseContent()))->filterByJsonPath($jsonPath);
}

/**
* Checks whether the last JSON response satisfies a JSON Payload Contract.
*
* This assertion validates and normalizes the response in one pass. Contract
* violations include the output field, stable violation code, message, and
* selector when available.
*
* JSON Payload Contract is an optional integration and requires PHP 8.3 or
* newer. Install it with:
*
* ``` shell
* composer require --dev softcreatr/json-payload-contract
* ```
*
* Example:
*
* ``` php
* <?php
* use SoftCreatR\JsonPayloadContract\Contract;
* use SoftCreatR\JsonPayloadContract\Field;
*
* $contract = Contract::define([
* 'id' => Field::required('$.data.user.id')
* ->fallback('$.user.id')
* ->integer(),
* 'email' => Field::required('$.data.user.email')
* ->fallback('$.user.email')
* ->string()
* ->email(),
* ]);
*
* $I->seeResponseMatchesJsonPayloadContract($contract);
* ```
*
* @part json
*/
public function seeResponseMatchesJsonPayloadContract(Contract $contract): void
{
$result = $contract->extractJson($this->connectionModule->_getResponseContent());

Assert::assertTrue($result->isValid(), $this->formatJsonPayloadContractViolations($result));
}

/**
* Returns normalized data from the last JSON response using a JSON Payload Contract.
*
* The action throws an extraction exception containing every contract
* violation when the response is malformed or does not satisfy the contract.
*
* Example:
*
* ``` php
* <?php
* $user = $I->grabDataFromResponseByJsonPayloadContract($contract);
* $I->sendPost('/users', $user);
* ```
*
* @part json
* @return array<string, mixed> Normalized contract output
* @throws \SoftCreatR\JsonPayloadContract\Exception\ExtractionException
*/
public function grabDataFromResponseByJsonPayloadContract(Contract $contract): array
{
return $contract->applyJson($this->connectionModule->_getResponseContent());
}

private function formatJsonPayloadContractViolations(Result $result): string
{
$violations = array_map(
static function (Violation $violation): string {
$selector = $violation->selector === null ? '' : sprintf(' (selector: %s)', $violation->selector);

return sprintf(
'- %s [%s]: %s%s',
$violation->field,
$violation->code->value,
$violation->message,
$selector
);
},
$result->violations()
);

return "Response does not satisfy the JSON payload contract:\n" . implode("\n", $violations);
}

/**
* Checks if json structure in response matches the xpath provided.
* JSON is not supposed to be checked against XPath, yet it can be converted to xml and used with XPath.
Expand Down
81 changes: 81 additions & 0 deletions tests/unit/Codeception/Module/RestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\ExpectationFailedException;
use SoftCreatR\JsonPayloadContract\Contract;
use SoftCreatR\JsonPayloadContract\Exception\ExtractionException;
use SoftCreatR\JsonPayloadContract\Field;
use Symfony\Component\BrowserKit\Request as SymfonyRequest;
use Symfony\Component\BrowserKit\Response as SymfonyResponse;

Expand Down Expand Up @@ -130,6 +133,77 @@ public function testGrabDataFromResponseByJsonPath()
$this->assertSame([], $this->module->grabDataFromResponseByJsonPath('$.address.street'));
}

public function testSeeResponseMatchesJsonPayloadContract()
{
$this->requireJsonPayloadContract();
$this->setStubResponse('{"data":{"user":{"id":42,"email":"john@example.com"}}}');

$contract = Contract::define([
'id' => Field::required('$.data.user.id')->integer(),
'email' => Field::required('$.data.user.email')->string()->email(),
]);

$this->module->seeResponseMatchesJsonPayloadContract($contract);
}

public function testSeeResponseMatchesJsonPayloadContractReportsAllViolations()
{
$this->requireJsonPayloadContract();
$this->setStubResponse('{"data":{"user":{"id":"42","email":"invalid"}}}');

$contract = Contract::define([
'id' => Field::required('$.data.user.id')->integer(),
'email' => Field::required('$.data.user.email')->string()->email(),
'name' => Field::required('$.data.user.name')->string(),
]);

$this->expectException(ExpectationFailedException::class);
$this->expectExceptionMessage(
"Response does not satisfy the JSON payload contract:\n"
. '- id [unexpected_type]: Expected integer, got string. (selector: $.data.user.id)' . "\n"
. '- email [invalid_email]: The value must be a valid email address. (selector: $.data.user.email)' . "\n"
. '- name [missing_required]: The required field did not match any value.'
);

$this->module->seeResponseMatchesJsonPayloadContract($contract);
}

public function testGrabDataFromResponseByJsonPayloadContractNormalizesFallbackData()
{
$this->requireJsonPayloadContract();
$this->setStubResponse('{"user":{"id":"42","roles":["admin","billing"]}}');

$contract = Contract::define([
'id' => Field::required('$.data.user.id')
->fallback('$.user.id')
->integer()
->coerce(),
'roles' => Field::many('$.data.user.roles[*]')
->fallback('$.user.roles[*]')
->string(),
]);

$this->assertSame(
['id' => 42, 'roles' => ['admin', 'billing']],
$this->module->grabDataFromResponseByJsonPayloadContract($contract)
);
}

public function testGrabDataFromResponseByJsonPayloadContractRejectsInvalidJson()
{
$this->requireJsonPayloadContract();
$this->setStubResponse('{invalid');

$contract = Contract::define([
'id' => Field::required('$.id')->integer(),
]);

$this->expectException(ExtractionException::class);
$this->expectExceptionMessage('Payload does not satisfy the contract: $: Syntax error');

$this->module->grabDataFromResponseByJsonPayloadContract($contract);
}

public function testValidJson()
{
$this->setStubResponse('{"xxx": "yyy"}');
Expand All @@ -139,6 +213,13 @@ public function testValidJson()
$this->module->seeResponseEquals('{"xxx": "yyy", "zzz": ["a","b"]}');
}

private function requireJsonPayloadContract(): void
{
if (!class_exists(Contract::class)) {
$this->markTestSkipped('JSON Payload Contract requires PHP 8.3 or newer.');
}
}

public function testInvalidJson()
{
$this->expectException(ExpectationFailedException::class);
Expand Down
Loading