Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
RequestValidationService
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
4 / 4
16
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validateRequest
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 extractParams
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 formatErrors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Shared\Infrastructure\Validation;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Psr\Http\Message\ServerRequestInterface;
12use Yiisoft\Hydrator\Hydrator;
13use Yiisoft\Hydrator\HydratorInterface;
14use Yiisoft\Validator\Result;
15use Yiisoft\Validator\Validator;
16use Yiisoft\Validator\ValidatorInterface;
17
18/**
19 * Central Request Validation and Hydration Service.
20 *
21 * Unified system using Yii3 Hydrator & Validator to hydrate untrusted PSR-7 request
22 * payloads into strongly-typed DTOs and validate their rules.
23 *
24 * @package App\Shared\Infrastructure\Validation
25 */
26final readonly class RequestValidationService
27{
28    private HydratorInterface $hydrator;
29    private ValidatorInterface $validator;
30
31    /**
32     * RequestValidationService constructor.
33     *
34     * @param HydratorInterface|null $hydrator Yii3 Hydrator instance.
35     * @param ValidatorInterface|null $validator Yii3 Validator instance.
36     */
37    public function __construct(
38        ?HydratorInterface $hydrator = null,
39        ?ValidatorInterface $validator = null
40    ) {
41        $this->hydrator = $hydrator ?? new Hydrator();
42        $this->validator = $validator ?? new Validator();
43    }
44
45    /**
46     * Hydrates PSR-7 request data into target DTO class and validates attribute rules.
47     *
48     * @template T of object
49     * @param ServerRequestInterface $request PSR-7 Server request.
50     * @param class-string<T> $targetClass Target DTO class name.
51     * @return ValidationResult<T> Validation result container.
52     */
53    public function validateRequest(ServerRequestInterface $request, string $targetClass): ValidationResult
54    {
55        $rawParams = $this->extractParams($request);
56        /** @var T $dto */
57        $dto = $this->hydrator->create($targetClass, $rawParams);
58
59        $result = $this->validator->validate($dto);
60        if ($result->isValid()) {
61            return new ValidationResult(true, $dto, []);
62        }
63
64        $formattedErrors = $this->formatErrors($result);
65        return new ValidationResult(false, $dto, $formattedErrors);
66    }
67
68    /**
69     * Extracts raw parameters from PSR-7 request query, parsed body, or raw body JSON.
70     *
71     * @param ServerRequestInterface $request PSR-7 Server request.
72     * @return array<string, mixed> Unified raw input parameters array.
73     */
74    private function extractParams(ServerRequestInterface $request): array
75    {
76        $queryParams = $request->getQueryParams();
77        $parsedBody = (array)($request->getParsedBody() ?? []);
78
79        if (empty($parsedBody) && $request->getMethod() === 'POST') {
80            $rawContent = (string)$request->getBody();
81            /** @var array<string, mixed> $parsedBody */
82            $parsedBody = json_decode($rawContent, true) ?? [];
83            if (empty($parsedBody) && $rawContent !== '') {
84        // @codeCoverageIgnoreStart
85                parse_str($rawContent, $parsedStr);
86                if (is_array($parsedStr)) {
87        // @codeCoverageIgnoreEnd
88        // @codeCoverageIgnoreStart
89        // @codeCoverageIgnoreEnd
90                    /** @var array<string, mixed> $parsedBody */
91        // @codeCoverageIgnoreStart
92                    $parsedBody = $parsedStr;
93        // @codeCoverageIgnoreEnd
94        // @codeCoverageIgnoreStart
95        // @codeCoverageIgnoreEnd
96                }
97            }
98        }
99
100        $merged = array_merge($queryParams, $parsedBody);
101
102        if (isset($merged['username']) && (!isset($merged['login']) || $merged['login'] === '')) {
103            $merged['login'] = (string)$merged['username'];
104        }
105
106        /** @var array<string, mixed> $cleaned */
107        $cleaned = [];
108        foreach ($merged as $k => $v) {
109            $cleaned[(string)$k] = is_string($v) ? trim($v) : $v;
110        }
111
112        return $cleaned;
113    }
114
115    /**
116     * Formats Yii3 Validator Result errors into array keyed by field name.
117     *
118     * @param Result $result Yii3 Validator Result object.
119     * @return array<string, array<int, string>> Formatted errors.
120     */
121    private function formatErrors(Result $result): array
122    {
123        $errors = [];
124        foreach ($result->getErrorMessagesIndexedByPath() as $path => $messages) {
125            $errors[(string)$path] = array_map(fn($msg) => (string)$msg, $messages);
126        }
127
128        return $errors;
129    }
130}