zjkiza/http-response-validator

A Symfony bundle for HTTP responses validating using a simple Result monad and handler chains.

Maintainers

Package info

github.com/zjkiza/http-response-validator

Type:symfony-bundle

pkg:composer/zjkiza/http-response-validator

Statistics

Installs: 75

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.11.2 2026-04-16 20:11 UTC

This package is auto-updated.

Last update: 2026-04-16 20:15:51 UTC


README

A Symfony bundle for HTTP responses validating using a simple Result monad and handler chains.

The main idea: the input is ResponseInterface (eg from symfony/http-client), then through a series of handlers (pipelines) the status code is validated, the content is logged, JSON is extracted and the structure is checked. Each handler returns a 'Result', so the chain breaks on the first error and throws an exception with a unique message ID.

For unit testing, you can use the PhpUnitTool to easily test handlers in isolation with custom inputs and expected outputs.

Characteristics

  • Declarative stacking steps over Result::success(...)->bind(...)
  • Built-in ready-to-use handlers:
    • ZJKiza\HttpResponseValidator\Handler\HttpResponseLoggerHandler – validates the expected status, logs the response, and masks sensitive keys.
    • ZJKiza\HttpResponseValidator\Handler\ExtractResponseJsonHandler – decodes the JSON body (associatively or as an object).
    • ZJKiza\HttpResponseValidator\Handler\ArrayStructureValidateExactHandler – validates the structure of the response using strict/exact key and type checking.
    • ZJKiza\HttpResponseValidator\Handler\ArrayStructureValidateInternalHandler – validates the structure using internal/relaxed rules for key existence and potential type checking.
  • Simple extension: add your own handler and register it with a single service tag.
  • Clear error messages with Message ID=<hex> for easy tracking in logs.
  • ZJKiza\HttpResponseValidator\PhpUnit\ArrayMatchesTrait enables simple and flexible testing of array structure and values (ex. API response) in PHPUnit tests.
    • assertArrayStructureAndValues(array $expected, array $actual) – asserts that the actual array has the same structure and values as the expected array, allowing for flexible matching (e.g., ignoring extra keys in the actual array).
    • assertArrayStrictStructureAndValues(array $expected, array $actual) – asserts that the actual array has the exact same structure and values as the expected array, including the same keys and values, without allowing for any extra keys in the actual array. Supports:
    • structure check,
    • value check,
    • strict and non-strict mode,
    • custom validators (callable).

Installation

Add "zjkiza/http-response-validator" to your composer.json file.

composer require zjkiza/http-response-validator

Symfony integration

Bundle wires up all classes together and provides method to easily setup.

  1. Register bundle within your configuration (i.e: bundles.php).
<?php

declare(strict_types=1);

return [
    // ...
    ZJKiza\HttpResponseValidator\ZJKizaHttpResponseValidatorBundle::class => ['all' => true],
];
  1. Bundle automatically registers services and factory class for handlers via service tag zjkiza.http_response_validate.handler_factory.

Quick start

Example with symfony/http-client and built-in handlers:

use Symfony\Component\HttpClient\HttpClient;
use ZJKiza\HttpResponseValidator\Monad\Result;
use ZJKiza\HttpResponseValidator\Contract\HandlerFactoryInterface;
use ZJKiza\HttpResponseValidator\Handler\HttpResponseLoggerHandler;
use ZJKiza\HttpResponseValidator\Handler\ExtractResponseJsonHandler;
use ZJKiza\HttpResponseValidator\Handler\ArrayStructureValidateExactHandler;
use ZJKiza\HttpResponseValidator\Handler\ArrayStructureValidateInternalHandler;

// ... in the service/controller with DI you get a handler factory
public function __construct(private HandlerFactoryInterface $handlerFactory) {}

public function fetch(): array
{
    $client = HttpClient::create();
    $response = $client->request('GET', 'https://example.com/api/user');

    // We arrange pipeline steps; getOrThrow() will throw an exception on error
    $data = Result::success($response)
        ->bind($this->handlerFactory->create(HttpResponseLoggerHandler::class)
            ->setExpectedStatus(200)
            ->addSensitiveKeys(['password', 'token']))
        ->bind($this->handlerFactory->create(ExtractResponseJsonHandler::class)
            ->setAssociative(true))
        ->bind($this->handlerFactory->create(ArrayStructureValidateInternalHandler::class)
            ->setKeys(['id', 'email']))
        ->getOrThrow();

    // $data is now an associative array with the required keys
    return $data;
}

If a step fails, an exception will be thrown with a message containing a unique `Message ID=...', and the error will be logged via the PSR‑3 logger.

Built-in Handlers

All handlers implement ZJKiza\HttpResponseValidator\Contract\HandlerInterface and expose a fluent API for configuration.

  • HttpResponseLoggerHandler

    • What it does: Validates the expected HTTP status, logs the response, and masks the values for the defined keys in the body.
    • Essential methods:
      • setExpectedStatus(int $status): self - set the expected HTTP status code (ex. 201, 404...). Default is 200.
      • addSensitiveKeys(string[] $keys): self - add keys to be masked in the logged response body (ex. 'password', 'token'). Masking replaces the value with '***' in logs.
  • ExtractResponseJsonHandler

    • What it does: calls $response->getContent(false), decodes the JSON, and returns the result as a string or object.
    • Essential methods:
      • setAssociative(bool $assoc = true): self – when true returns an associative array; when false is an object.
  • ArrayStructureValidateExactHandler

    • What it does: Validates array structure with exact match: same keys as expected, possible deep (nested), optional type checks.
    • Essential methods:
      • setKeys(array $structure): self – associative or indexed array describing expected structure.
      • setIgnoreNulls(bool $ignoreNulls = false): self – ignore null values if set. If a key/keys is defined in the structure but the value is null, it will be ignored and not treated as an error.
      • setCheckTypes(bool $checkTypes = false): self – enable type checking when structure contains types. Supported types: string, int, float, bool, array, object, null, mixed. You can also use union types (ex. 'int|string') and checking whether all elements of the array belong to a certain type (ex. int[], string[], float[]...).
  • ArrayStructureValidateInternalHandler

    • What it does: Validates array structure with more internally permissive (flexible) rules, suitable for partial checks.
    • Essential methods:
      • setKeys(array $structure): self – associative or indexed array describing expected structure.
      • setIgnoreNulls(bool $ignoreNulls = false): self – ignore null values if set. If a key/keys is defined in the structure but the value is null, it will be ignored and not treated as an error.
      • setCheckTypes(bool $checkTypes = false): self – enable type checking when structure contains types. Supported types: string, int, float, bool, array, object, null, mixed. You can also use union types (ex. 'int|string') and checking whether all elements of the array belong to a certain type (ex. int[], string[], float[]...).

Direct Use: ArrayStructureExactValidation and ArrayStructureInternalValidation

Validation services can be used standalone without the handler pipeline. For example, to validate data against a strict structure with type (string, int, float, bool, array, object, null, mixed) checking. It has the possibility to check more types as soon as they are separated | (ex : 'int|string') and checking whether all elements of the array belong to a certain type (ex. int[], string[], float[]...).

use ZJKiza\HttpResponseValidator\Validator\ArrayStructureExactValidation;
use ZJKiza\HttpResponseValidator\Validator\Helper\ErrorCollector;

$validator = new ArrayStructureExactValidation(new ErrorCollector(), $ignoreNulls = false, $checkTypes = true);

$validator->validate($structure, $data);

if ($validator->getErrorCollector()->hasErrors()) {
    // Handle or inspect `$validator->getErrorCollector()->all()`
}

For internal/permissive validation:

use ZJKiza\HttpResponseValidator\Validator\ArrayStructureInternalValidation;
use ZJKiza\HttpResponseValidator\Validator\Helper\ErrorCollector;

$validator = new ArrayStructureInternalValidation(new ErrorCollector(), $ignoreNulls = false, $checkTypes = true);

$validator->validate($structure, $data);

if ($validator->getErrorCollector()->hasErrors()) {
    // Handle or inspect errors
}

Example (Handler Pipeline)

// ...
$data = Result::success($response)
    ->bind($this->handlerFactory->create(HttpResponseLoggerHandler::class)->setExpectedStatus(200))
    ->bind($this->handlerFactory->create(ExtractResponseJsonHandler::class)->setAssociative(true))
    ->bind($this->handlerFactory->create(ArrayStructureValidateExactHandler::class)
        ->setKeys([
            'id' => 'int|string', 
            'email' => 'string',
            'addresses' => 'string[]', // array of strings
        ])
        ->setCheckTypes(true)
        ->setIgnoreNulls(false)
    )
    ->getOrThrow();

Example for data

       $data = [
            'args' => [
                'test' => '123',
            ],
            'headers' => [
                'host' => 'postman-echo.com',
                'dnt' => 1.23,
                'foo' => 'bool',
                'ad' => [
                    'bb' => [],
                    'cc' => new class () {
                    },
                    'dd' => null,
                ],
            ],
            'body' => [
                'items' => [
                    [
                        'name' => 'name1',
                        'age' => 20,
                    ],
                    [
                        'name' => 'name2',
                        'age' => 22,
                    ],
                ],
                'errors' => [
                    'error 1',
                    'error 2',
                    'error 3',
                ],
            ],
        ];

The validation would look like

    ... 
    ->bind($this->handlerFactory->create(ArrayStructureValidateExactHandler::class)
        ->setKeys([
            'args' => [
                'test' => 'string',
            ],
            'headers' => [
                'host' => 'string',
                'dnt' => 'float',
                'foo' => true,
                'ad' => [
                    'bb' => 'array',
                    'cc' => 'object',
                    'dd' => 'null',
                ],
            ],
            'body' => [
                'items' => [
                    '*' => [
                        'name' => 'string',
                        'age' => 'int',
                    ],
                ],
                'errors' => 'string[]'
            ],
        ])
        ->setCheckTypes(true)
        ->setIgnoreNulls(false)
    )
    ...

How to add your own Handler

  1. Create a class that implements HandlerInterface (recommendation: inherit from AbstractHandler and use the TagIndexMethod trait). Example: validating the format of an email field in a string.
<?php

declare(strict_types=1);

namespace App\HttpResponse\Handler;

use ZJKiza\HttpResponseValidator\Handler\AbstractHandler;
use ZJKiza\HttpResponseValidator\Handler\Factory\TagIndexMethod;
use ZJKiza\HttpResponseValidator\Contract\HandlerInterface;
use ZJKiza\HttpResponseValidator\Monad\Result;

/**
 * @implements HandlerInterface<array<string,mixed>, array<string,mixed>>
 */
final class ValidateEmailFormatHandler extends AbstractHandler implements HandlerInterface
{
    use TagIndexMethod; // automates the static index for factory registration

    public function __invoke(mixed $value): Result
    {
        if (!isset($value['email']) || !\filter_var($value['email'], FILTER_VALIDATE_EMAIL)) {
            return $this->fail('Email is not valid..');
        }

        return Result::success($value);
    }
}
  1. Register the service and be sure to add a tag zjkiza.http_response_validate.handler_factory:
# config/services.yaml
services:
  App\HttpResponse\Handler\ValidateEmailFormatHandler:
    tags:
      - { name: 'zjkiza.http_response_validate.handler_factory' }
  1. Use it in a chain across HandlerFactoryInterface:
$result = Result::success($response)
    ->bind($handlerFactory->create(HttpResponseLoggerHandler::class)->setExpectedStatus(200))
    ->bind($handlerFactory->create(ExtractResponseJsonHandler::class)->setAssociative(true))
    ->bind($handlerFactory->create(ValidateEmailFormatHandler::class))
    ->getOrThrow();

Note: if you don't want to use the TagIndexMethod, make sure your class implements the static getIndex(): string method that returns a unique index (most commonly FQCN).

Usage PhpUnitTool

In your PHPUnit test, include the trait:

use PHPUnit\Framework\TestCase;
use ZJKiza\HttpResponseValidator\PhpUnit\ArrayMatchesTrait;

class ApiTest extends TestCase
{
    use ArrayMatchesTrait;
}

Methods

  1. assertArrayStructureAndValues(array $actual, array $expected)

Partial match (not strict)

  • additional keys are allowed in $actual,
  • only checks what is defined in $expected.

Exsample:

$this->assertArrayStructureAndValues(
    $actual,
    [
        'name' => 'John',
        'email' => 'john@test.com',
    ]
);

passes if $actual has more fields (ex. id, createdAt, etc.)

  1. assertArrayStrictStructureAndValues(array $actual, array $expected)

Strict match

  • additional keys are NOT allowed in $actual,
  • checks that $actual has exactly the same keys and values as $expected.
$this->assertArrayStrictStructureAndValues(
    $actual,
    [
        'name' => 'John',
        'email' => 'john@test.com',
    ]
);

must be exactly identical (except for the order in the associative array).

Rules of conduct

  • Associative array
$expected = [
    'a' => 1,
    'b' => 2,
];
  1. the order does not matter,
  2. keys and values must exist.
  • List (numeric array)
$expected = [1, 2, 3];
  1. order is mandatory,
  2. the size must be the same.

Custom validate (callable)

You can use closure for complex checks:

$this->assertArrayStrictStructureAndValues(
    $actual,
    [
        'user' => [
            'id' => fn($v) => $this->assertIsInt($v),
            'email' => 'test@example.com',
        ],
        'roles' => ['ROLE_USER', 'ROLE_ADMIN'],
    ]
);

$this->assertArrayStrictStructureAndValues(
    $actual,
    [
        'level' => 'error',
        'message' => function (string $message): void {
            $pattern = '/^\[ZJKiza\\\\HttpResponseValidator\\\\Handler\\\\ArrayStructureValidateExactHandler\] Message ID=[a-f0-9]+ ?: +PHPUnit\\\\Framework\\\\TestCase::runTest -> \[ArrayStructureValidateExactHandler\] Errors: Exact key mismatch at \"root\.headers\.bar\"\. Expected: PHPUnit\\\\Framework\\\\TestCase::runTest -> \[\"barKey1\"\], got: PHPUnit\\\\Framework\\\\TestCase::runTest -> \[\"barKey1\",\"barKey2\"\]\.$/';
            self::assertThat($message, new RegularExpression($pattern));
        },
    ]
);

Notice

  • The trait must be used in a class that inherits from PHPUnit\Framework\TestCase,
  • assertArrayStructureAndValues allows additional keys,
  • assertArrayStrictStructureAndValues requires an exact match.

Logging in and message ID

Errors are logged via the PSR‑3 logger. Messages are automatically prefixed with Message ID=<hex> (see helper ZJKiza\HttpResponseValidator\addIdInMessage()), which facilitates correlation between logs and client messages.

Development and tests (optional for contributors)

composer phpunit
composer phpstan
composer psalm
composer php-cs-fixer

License

MIT. View the `LICENSE' file in the repository.

What else needs to be done

[ ] Update readme

[+] When ValidateArrayKeysExistHandler encounters the first error in the keys, it does not immediately throw an exception, but collects all the missing keys and only then throws the exception.