Files
sam-manage/app/Services/FlowTester/ResponseValidator.php
kent 8b82d23cdf feat: [flow-tester] ResponseValidator에 @isObject 연산자 추가
JSON 응답에서 객체(연관 배열) 타입을 검증하는 @isObject 연산자 구현
- array_is_list()로 순차 배열과 연관 배열 구분
- $.user 등 객체 필드 검증 시 사용

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 04:13:23 +09:00

319 lines
11 KiB
PHP

<?php
namespace App\Services\FlowTester;
/**
* 응답 검증기
*
* HTTP 응답을 기대값(expect)과 비교하여 검증합니다.
*
* 지원 연산자:
* - 직접 값: "$.success": true
* - @exists: 존재 체크
* - @isNumber: 숫자 타입 체크
* - @isString: 문자열 타입 체크
* - @isArray: 배열 타입 체크
* - @isObject: 객체(연관 배열) 타입 체크
* - @isBoolean: 불리언 타입 체크
* - @minLength:N: 최소 길이 (배열/문자열)
* - @maxLength:N: 최대 길이 (배열/문자열)
* - @regex:pattern: 정규식 매칭
* - @gt:N: N보다 큼
* - @gte:N: N 이상
* - @lt:N: N보다 작음
* - @lte:N: N 이하
*/
class ResponseValidator
{
/**
* 응답 검증
*
* @param array $response HTTP 응답 ['status' => int, 'body' => array]
* @param array $expect 기대값 정의
* @return array ['success' => bool, 'errors' => array]
*/
public function validate(array $response, array $expect): array
{
$errors = [];
// 상태 코드 검증
if (isset($expect['status'])) {
$allowedStatus = (array) $expect['status'];
$actualStatus = $response['status'] ?? 0;
if (! in_array($actualStatus, $allowedStatus)) {
$errors[] = sprintf(
'Expected status %s, got %d',
$this->formatList($allowedStatus),
$actualStatus
);
}
}
// JSONPath 검증
if (isset($expect['jsonPath'])) {
foreach ($expect['jsonPath'] as $path => $expected) {
$actual = $this->getValueByPath($response['body'] ?? [], $path);
$pathError = $this->validateValue($actual, $expected, $path);
if ($pathError) {
$errors[] = $pathError;
}
}
}
return [
'success' => empty($errors),
'errors' => $errors,
];
}
/**
* JSONPath로 값 추출
*
* @param array $data 데이터
* @param string $path 경로 ($.data.id 형식)
*/
private function getValueByPath(array $data, string $path): mixed
{
// $. 접두사 제거
$path = ltrim($path, '$.');
if (empty($path)) {
return $data;
}
return data_get($data, $path);
}
/**
* 개별 값 검증
*/
private function validateValue(mixed $actual, mixed $expected, string $path): ?string
{
// 직접 값 비교 (연산자가 아닌 경우)
if (! is_string($expected) || ! str_starts_with($expected, '@')) {
// 숫자 비교: 둘 다 숫자(또는 숫자 문자열)인 경우 타입 무관하게 비교
// 예: "2" == 2, "123" == 123
if ($this->areNumericEqual($actual, $expected)) {
return null;
}
if ($actual !== $expected) {
return sprintf(
'Path %s: expected %s, got %s',
$path,
json_encode($expected, JSON_UNESCAPED_UNICODE),
json_encode($actual, JSON_UNESCAPED_UNICODE)
);
}
return null;
}
// 연산자 처리
$operator = substr($expected, 1);
return match (true) {
$operator === 'exists' => $actual === null ? "Path {$path}: expected to exist" : null,
$operator === 'notExists' => $actual !== null ? "Path {$path}: expected to not exist" : null,
$operator === 'isNumber' => ! is_numeric($actual) ? "Path {$path}: expected number, got ".gettype($actual) : null,
$operator === 'isString' => ! is_string($actual) ? "Path {$path}: expected string, got ".gettype($actual) : null,
$operator === 'isArray' => ! is_array($actual) ? "Path {$path}: expected array, got ".gettype($actual) : null,
$operator === 'isBoolean' => ! is_bool($actual) ? "Path {$path}: expected boolean, got ".gettype($actual) : null,
$operator === 'isObject' => ! is_array($actual) || array_is_list($actual) ? "Path {$path}: expected object, got ".(is_array($actual) ? 'array' : gettype($actual)) : null,
$operator === 'isNull' => $actual !== null ? "Path {$path}: expected null, got ".gettype($actual) : null,
$operator === 'notNull' => $actual === null ? "Path {$path}: expected not null" : null,
$operator === 'isEmpty' => ! empty($actual) ? "Path {$path}: expected empty" : null,
$operator === 'notEmpty' => empty($actual) ? "Path {$path}: expected not empty" : null,
str_starts_with($operator, 'minLength:') => $this->validateMinLength($actual, $operator, $path),
str_starts_with($operator, 'maxLength:') => $this->validateMaxLength($actual, $operator, $path),
str_starts_with($operator, 'regex:') => $this->validateRegex($actual, $operator, $path),
str_starts_with($operator, 'gt:') => $this->validateGreaterThan($actual, $operator, $path),
str_starts_with($operator, 'gte:') => $this->validateGreaterThanOrEqual($actual, $operator, $path),
str_starts_with($operator, 'lt:') => $this->validateLessThan($actual, $operator, $path),
str_starts_with($operator, 'lte:') => $this->validateLessThanOrEqual($actual, $operator, $path),
str_starts_with($operator, 'contains:') => $this->validateContains($actual, $operator, $path),
default => "Path {$path}: unknown operator @{$operator}",
};
}
private function validateMinLength(mixed $actual, string $operator, string $path): ?string
{
$min = (int) substr($operator, 10); // "minLength:" 길이
if (is_array($actual)) {
if (count($actual) < $min) {
return "Path {$path}: expected array with min length {$min}, got ".count($actual);
}
} elseif (is_string($actual)) {
if (mb_strlen($actual) < $min) {
return "Path {$path}: expected string with min length {$min}, got ".mb_strlen($actual);
}
} else {
return "Path {$path}: minLength requires array or string";
}
return null;
}
private function validateMaxLength(mixed $actual, string $operator, string $path): ?string
{
$max = (int) substr($operator, 10); // "maxLength:" 길이
if (is_array($actual)) {
if (count($actual) > $max) {
return "Path {$path}: expected array with max length {$max}, got ".count($actual);
}
} elseif (is_string($actual)) {
if (mb_strlen($actual) > $max) {
return "Path {$path}: expected string with max length {$max}, got ".mb_strlen($actual);
}
} else {
return "Path {$path}: maxLength requires array or string";
}
return null;
}
private function validateRegex(mixed $actual, string $operator, string $path): ?string
{
$pattern = '/'.substr($operator, 6).'/'; // "regex:" 제거
if (! is_string($actual)) {
return "Path {$path}: regex requires string value";
}
if (! preg_match($pattern, $actual)) {
return "Path {$path}: value does not match pattern {$pattern}";
}
return null;
}
private function validateGreaterThan(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 3); // "gt:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: gt requires numeric value";
}
if ($actual <= $threshold) {
return "Path {$path}: expected > {$threshold}, got {$actual}";
}
return null;
}
private function validateGreaterThanOrEqual(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 4); // "gte:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: gte requires numeric value";
}
if ($actual < $threshold) {
return "Path {$path}: expected >= {$threshold}, got {$actual}";
}
return null;
}
private function validateLessThan(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 3); // "lt:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: lt requires numeric value";
}
if ($actual >= $threshold) {
return "Path {$path}: expected < {$threshold}, got {$actual}";
}
return null;
}
private function validateLessThanOrEqual(mixed $actual, string $operator, string $path): ?string
{
$threshold = (float) substr($operator, 4); // "lte:" 제거
if (! is_numeric($actual)) {
return "Path {$path}: lte requires numeric value";
}
if ($actual > $threshold) {
return "Path {$path}: expected <= {$threshold}, got {$actual}";
}
return null;
}
private function validateContains(mixed $actual, string $operator, string $path): ?string
{
$needle = substr($operator, 9); // "contains:" 제거
if (is_string($actual)) {
if (! str_contains($actual, $needle)) {
return "Path {$path}: expected to contain '{$needle}'";
}
} elseif (is_array($actual)) {
if (! in_array($needle, $actual)) {
return "Path {$path}: expected array to contain '{$needle}'";
}
} else {
return "Path {$path}: contains requires string or array";
}
return null;
}
private function formatList(array $items): string
{
return '['.implode(', ', $items).']';
}
/**
* 숫자 값 비교 (타입 무관)
*
* 둘 다 숫자(또는 숫자 문자열)인 경우 값이 같은지 비교합니다.
* 예: "2" == 2 → true, "123" == 123 → true
*/
private function areNumericEqual(mixed $actual, mixed $expected): bool
{
// 둘 다 숫자 또는 숫자 문자열인 경우에만 비교
if (is_numeric($actual) && is_numeric($expected)) {
// 정수 비교가 가능한 경우 정수로 비교
if (is_int($actual) || is_int($expected) ||
(is_string($actual) && ctype_digit(ltrim($actual, '-'))) ||
(is_string($expected) && ctype_digit(ltrim($expected, '-')))) {
return (int) $actual === (int) $expected;
}
// 그 외의 경우 float로 비교
return (float) $actual === (float) $expected;
}
return false;
}
/**
* 응답에서 값 추출 (extract 처리)
*
* @param array $body 응답 바디
* @param array $extract 추출 정의 ['pageId' => '$.data.id']
* @return array 추출된 값들
*/
public function extractValues(array $body, array $extract): array
{
$result = [];
foreach ($extract as $name => $path) {
$result[$name] = $this->getValueByPath($body, $path);
}
return $result;
}
}