From 367f2159d1a002316b6bd57baae56c0786de7cdd Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 27 Nov 2025 19:20:07 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20API=20Flow=20Tester=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(Day=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VariableBinder: 변수 바인딩 엔진 ({{...}} 패턴 처리) - DependencyResolver: 의존성 정렬 (Topological Sort) - ResponseValidator: HTTP 응답 검증 (JSONPath, 연산자) - HttpClient: Laravel HTTP Client 래퍼 - FlowExecutor: 플로우 실행 엔진 --- .../FlowTester/DependencyResolver.php | 163 ++++++++ app/Services/FlowTester/FlowExecutor.php | 374 ++++++++++++++++++ app/Services/FlowTester/HttpClient.php | 226 +++++++++++ app/Services/FlowTester/ResponseValidator.php | 286 ++++++++++++++ app/Services/FlowTester/VariableBinder.php | 192 +++++++++ 5 files changed, 1241 insertions(+) create mode 100644 app/Services/FlowTester/DependencyResolver.php create mode 100644 app/Services/FlowTester/FlowExecutor.php create mode 100644 app/Services/FlowTester/HttpClient.php create mode 100644 app/Services/FlowTester/ResponseValidator.php create mode 100644 app/Services/FlowTester/VariableBinder.php diff --git a/app/Services/FlowTester/DependencyResolver.php b/app/Services/FlowTester/DependencyResolver.php new file mode 100644 index 00000000..a20c499c --- /dev/null +++ b/app/Services/FlowTester/DependencyResolver.php @@ -0,0 +1,163 @@ + $degree) { + if ($degree === 0) { + $queue[] = $id; + } + } + + $sorted = []; + while (! empty($queue)) { + $current = array_shift($queue); + $sorted[] = $current; + + foreach ($graph[$current] as $neighbor) { + $inDegree[$neighbor]--; + if ($inDegree[$neighbor] === 0) { + $queue[] = $neighbor; + } + } + } + + if (count($sorted) !== count($steps)) { + // 순환 의존성 발견 - 어떤 스텝들이 문제인지 파악 + $remaining = array_diff(array_keys($graph), $sorted); + throw new Exception('Circular dependency detected in steps: '.implode(', ', $remaining)); + } + + return $sorted; + } + + /** + * 의존성 그래프 시각화 (디버깅용) + * + * @param array $steps 스텝 정의 배열 + * @return array 의존성 정보 + */ + public function visualize(array $steps): array + { + $result = []; + + foreach ($steps as $step) { + $id = $step['id']; + $deps = $step['dependsOn'] ?? []; + + $result[$id] = [ + 'name' => $step['name'] ?? $id, + 'depends_on' => $deps, + 'depended_by' => [], + ]; + } + + // 역방향 의존성 추가 + foreach ($steps as $step) { + $id = $step['id']; + $deps = $step['dependsOn'] ?? []; + + foreach ($deps as $dep) { + if (isset($result[$dep])) { + $result[$dep]['depended_by'][] = $id; + } + } + } + + return $result; + } + + /** + * 의존성 유효성 검사 + * + * @param array $steps 스텝 정의 배열 + * @return array ['valid' => bool, 'errors' => array] + */ + public function validate(array $steps): array + { + $errors = []; + $stepIds = array_column($steps, 'id'); + + // 중복 ID 체크 + $duplicates = array_diff_assoc($stepIds, array_unique($stepIds)); + if (! empty($duplicates)) { + $errors[] = 'Duplicate step IDs found: '.implode(', ', array_unique($duplicates)); + } + + // 존재하지 않는 의존성 체크 + foreach ($steps as $step) { + $id = $step['id']; + $deps = $step['dependsOn'] ?? []; + + foreach ($deps as $dep) { + if (! in_array($dep, $stepIds)) { + $errors[] = "Step '{$id}' depends on unknown step '{$dep}'"; + } + + // 자기 참조 체크 + if ($dep === $id) { + $errors[] = "Step '{$id}' cannot depend on itself"; + } + } + } + + // 순환 의존성 체크 + try { + $this->resolve($steps); + } catch (Exception $e) { + $errors[] = $e->getMessage(); + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } +} diff --git a/app/Services/FlowTester/FlowExecutor.php b/app/Services/FlowTester/FlowExecutor.php new file mode 100644 index 00000000..58adca3e --- /dev/null +++ b/app/Services/FlowTester/FlowExecutor.php @@ -0,0 +1,374 @@ +binder = $binder ?? new VariableBinder; + $this->resolver = $resolver ?? new DependencyResolver; + $this->validator = $validator ?? new ResponseValidator; + $this->httpClient = $httpClient ?? new HttpClient; + } + + /** + * 플로우 실행 + * + * @param array $flowDefinition 플로우 정의 JSON + * @param array $inputVariables 입력 변수 (실행 시 주입) + * @return array 실행 결과 + */ + public function execute(array $flowDefinition, array $inputVariables = []): array + { + $startTime = microtime(true); + $this->reset(); + + try { + // 1. 플로우 정의 검증 + $this->validateFlowDefinition($flowDefinition); + + // 2. 설정 적용 + $this->applyConfig($flowDefinition['config'] ?? []); + + // 3. 전역 변수 초기화 + $variables = array_merge( + $flowDefinition['variables'] ?? [], + $inputVariables + ); + $this->binder->setVariables($variables); + + // 4. 스텝 정보 추출 + $steps = $flowDefinition['steps'] ?? []; + $this->totalSteps = count($steps); + + if ($this->totalSteps === 0) { + return $this->buildResult($startTime, 'SUCCESS', 'No steps to execute'); + } + + // 5. 의존성 정렬 + $orderedStepIds = $this->resolver->resolve($steps); + $stepMap = $this->buildStepMap($steps); + + // 6. 실행 상태 변경 + $this->status = 'RUNNING'; + + // 7. 각 단계 실행 + $stopOnFailure = $flowDefinition['config']['stopOnFailure'] ?? true; + + foreach ($orderedStepIds as $stepId) { + $step = $stepMap[$stepId]; + $stepResult = $this->executeStep($step); + + $this->executionLog[] = $stepResult; + + if ($stepResult['success']) { + $this->completedSteps++; + } else { + if (! ($step['continueOnFailure'] ?? false) && $stopOnFailure) { + $this->status = 'FAILED'; + + return $this->buildResult( + $startTime, + 'FAILED', + "Step '{$stepId}' failed: ".($stepResult['error'] ?? 'Unknown error'), + $stepId + ); + } + // continueOnFailure가 true면 계속 진행 + } + } + + // 8. 최종 상태 결정 + $this->status = $this->completedSteps === $this->totalSteps ? 'SUCCESS' : 'PARTIAL'; + + return $this->buildResult($startTime, $this->status); + } catch (Exception $e) { + $this->status = 'FAILED'; + + return $this->buildResult($startTime, 'FAILED', $e->getMessage()); + } + } + + /** + * 단일 스텝 실행 + */ + private function executeStep(array $step): array + { + $stepId = $step['id']; + $stepName = $step['name'] ?? $stepId; + $startTime = microtime(true); + + try { + // 1. 딜레이 적용 + $delay = $step['delay'] ?? 0; + if ($delay > 0) { + usleep($delay * 1000); // ms → μs + } + + // 2. 변수 바인딩 + $endpoint = $this->binder->bind($step['endpoint']); + $headers = $this->binder->bind($step['headers'] ?? []); + $body = $this->binder->bind($step['body'] ?? []); + + // 3. HTTP 요청 실행 + $method = strtoupper($step['method']); + $response = $this->httpClient->request($method, $endpoint, [ + 'headers' => $headers, + 'body' => $body, + ]); + + // 4. HTTP 에러 체크 + if ($response['error']) { + return $this->buildStepResult($stepId, $stepName, $startTime, false, [ + 'error' => $response['error'], + 'request' => [ + 'method' => $method, + 'endpoint' => $endpoint, + 'headers' => $headers, + 'body' => $body, + ], + ]); + } + + // 5. 응답 검증 + $expect = $step['expect'] ?? []; + $validation = $this->validator->validate($response, $expect); + + // 6. 변수 추출 + $extracted = []; + if (isset($step['extract'])) { + $extracted = $this->validator->extractValues($response['body'], $step['extract']); + $this->binder->setStepResult($stepId, $extracted, $response['body']); + } + + // 7. 결과 구성 + $success = $validation['success']; + + return $this->buildStepResult($stepId, $stepName, $startTime, $success, [ + 'request' => [ + 'method' => $method, + 'endpoint' => $endpoint, + 'headers' => $headers, + 'body' => $body, + ], + 'response' => [ + 'status' => $response['status'], + 'body' => $response['body'], + 'duration' => $response['duration'], + ], + 'extracted' => $extracted, + 'validation' => $validation, + 'error' => $success ? null : implode('; ', $validation['errors']), + ]); + } catch (Exception $e) { + return $this->buildStepResult($stepId, $stepName, $startTime, false, [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 플로우 정의 검증 + */ + private function validateFlowDefinition(array $definition): void + { + // steps 필드 필수 + if (! isset($definition['steps']) || ! is_array($definition['steps'])) { + throw new Exception('Flow definition must have a "steps" array'); + } + + // 각 스텝 검증 + foreach ($definition['steps'] as $index => $step) { + if (! isset($step['id'])) { + throw new Exception("Step at index {$index} must have an 'id' field"); + } + if (! isset($step['method'])) { + throw new Exception("Step '{$step['id']}' must have a 'method' field"); + } + if (! isset($step['endpoint'])) { + throw new Exception("Step '{$step['id']}' must have an 'endpoint' field"); + } + } + + // 의존성 검증 + $validation = $this->resolver->validate($definition['steps']); + if (! $validation['valid']) { + throw new Exception('Dependency validation failed: '.implode('; ', $validation['errors'])); + } + } + + /** + * 설정 적용 + */ + private function applyConfig(array $config): void + { + // Base URL + if (isset($config['baseUrl'])) { + $this->httpClient->setBaseUrl($config['baseUrl']); + } + + // Timeout + if (isset($config['timeout'])) { + $timeout = (int) ($config['timeout'] / 1000); // ms → s + $this->httpClient->setTimeout(max(1, $timeout)); + } + + // 기본 헤더 + if (isset($config['headers'])) { + $this->httpClient->setDefaultHeaders($config['headers']); + } + + // 인증 + if (isset($config['apiKey'])) { + $this->httpClient->setApiKey($config['apiKey']); + } + if (isset($config['bearerToken'])) { + $this->httpClient->setBearerToken($config['bearerToken']); + } + } + + /** + * 스텝 맵 구성 (id → step) + */ + private function buildStepMap(array $steps): array + { + $map = []; + foreach ($steps as $step) { + $map[$step['id']] = $step; + } + + return $map; + } + + /** + * 스텝 결과 구성 + */ + private function buildStepResult( + string $stepId, + string $stepName, + float $startTime, + bool $success, + array $details = [] + ): array { + $duration = (int) ((microtime(true) - $startTime) * 1000); + + return array_merge([ + 'stepId' => $stepId, + 'stepName' => $stepName, + 'success' => $success, + 'duration' => $duration, + 'timestamp' => date('Y-m-d H:i:s'), + ], $details); + } + + /** + * 최종 결과 구성 + */ + private function buildResult( + float $startTime, + string $status, + ?string $errorMessage = null, + ?string $failedStep = null + ): array { + $duration = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'status' => $status, + 'duration' => $duration, + 'totalSteps' => $this->totalSteps, + 'completedSteps' => $this->completedSteps, + 'failedStep' => $failedStep, + 'errorMessage' => $errorMessage, + 'executionLog' => $this->executionLog, + 'startedAt' => date('Y-m-d H:i:s', (int) $startTime), + 'completedAt' => date('Y-m-d H:i:s'), + ]; + } + + /** + * 상태 초기화 + */ + private function reset(): void + { + $this->executionLog = []; + $this->status = 'PENDING'; + $this->completedSteps = 0; + $this->totalSteps = 0; + $this->binder->reset(); + } + + /** + * 현재 진행 상황 조회 + */ + public function getProgress(): array + { + return [ + 'status' => $this->status, + 'completedSteps' => $this->completedSteps, + 'totalSteps' => $this->totalSteps, + 'percent' => $this->totalSteps > 0 + ? round(($this->completedSteps / $this->totalSteps) * 100, 1) + : 0, + ]; + } + + /** + * 실행 로그 조회 + */ + public function getExecutionLog(): array + { + return $this->executionLog; + } + + /** + * 현재 상태 조회 + */ + public function getStatus(): string + { + return $this->status; + } +} diff --git a/app/Services/FlowTester/HttpClient.php b/app/Services/FlowTester/HttpClient.php new file mode 100644 index 00000000..b0f702c8 --- /dev/null +++ b/app/Services/FlowTester/HttpClient.php @@ -0,0 +1,226 @@ +baseUrl = rtrim($url, '/'); + + return $this; + } + + /** + * 타임아웃 설정 (초) + */ + public function setTimeout(int $seconds): self + { + $this->timeout = $seconds; + + return $this; + } + + /** + * 기본 헤더 설정 + */ + public function setDefaultHeaders(array $headers): self + { + $this->defaultHeaders = $headers; + + return $this; + } + + /** + * API 키 설정 (X-API-KEY 헤더) + */ + public function setApiKey(string $apiKey): self + { + $this->apiKey = $apiKey; + + return $this; + } + + /** + * Bearer 토큰 설정 + */ + public function setBearerToken(string $token): self + { + $this->bearerToken = $token; + + return $this; + } + + /** + * HTTP 요청 실행 + * + * @param string $method HTTP 메서드 + * @param string $endpoint 엔드포인트 + * @param array $options 옵션 ['headers' => [], 'body' => [], 'query' => []] + * @return array ['status' => int, 'headers' => array, 'body' => array, 'duration' => int] + */ + public function request(string $method, string $endpoint, array $options = []): array + { + $startTime = microtime(true); + + $url = $this->buildUrl($endpoint); + $headers = $this->buildHeaders($options['headers'] ?? []); + + try { + $request = Http::timeout($this->timeout) + ->withHeaders($headers); + + // API 키 추가 + if ($this->apiKey) { + $request = $request->withHeaders(['X-API-KEY' => $this->apiKey]); + } + + // Bearer 토큰 추가 + if ($this->bearerToken) { + $request = $request->withToken($this->bearerToken); + } + + // 쿼리 파라미터 + if (! empty($options['query'])) { + $url .= '?'.http_build_query($options['query']); + } + + // 요청 실행 + $response = match (strtoupper($method)) { + 'GET' => $request->get($url), + 'POST' => $request->post($url, $options['body'] ?? []), + 'PUT' => $request->put($url, $options['body'] ?? []), + 'PATCH' => $request->patch($url, $options['body'] ?? []), + 'DELETE' => $request->delete($url, $options['body'] ?? []), + default => throw new Exception("Unsupported HTTP method: {$method}"), + }; + + $duration = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'status' => $response->status(), + 'headers' => $response->headers(), + 'body' => $response->json() ?? [], + 'duration' => $duration, + 'success' => $response->successful(), + 'error' => null, + ]; + } catch (Exception $e) { + $duration = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'status' => 0, + 'headers' => [], + 'body' => [], + 'duration' => $duration, + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } + + /** + * GET 요청 + */ + public function get(string $endpoint, array $query = [], array $headers = []): array + { + return $this->request('GET', $endpoint, [ + 'query' => $query, + 'headers' => $headers, + ]); + } + + /** + * POST 요청 + */ + public function post(string $endpoint, array $body = [], array $headers = []): array + { + return $this->request('POST', $endpoint, [ + 'body' => $body, + 'headers' => $headers, + ]); + } + + /** + * PUT 요청 + */ + public function put(string $endpoint, array $body = [], array $headers = []): array + { + return $this->request('PUT', $endpoint, [ + 'body' => $body, + 'headers' => $headers, + ]); + } + + /** + * PATCH 요청 + */ + public function patch(string $endpoint, array $body = [], array $headers = []): array + { + return $this->request('PATCH', $endpoint, [ + 'body' => $body, + 'headers' => $headers, + ]); + } + + /** + * DELETE 요청 + */ + public function delete(string $endpoint, array $body = [], array $headers = []): array + { + return $this->request('DELETE', $endpoint, [ + 'body' => $body, + 'headers' => $headers, + ]); + } + + /** + * 전체 URL 구성 + */ + private function buildUrl(string $endpoint): string + { + $endpoint = ltrim($endpoint, '/'); + + if (empty($this->baseUrl)) { + return $endpoint; + } + + return $this->baseUrl.'/'.$endpoint; + } + + /** + * 헤더 병합 + */ + private function buildHeaders(array $additionalHeaders): array + { + return array_merge( + [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + $this->defaultHeaders, + $additionalHeaders + ); + } +} diff --git a/app/Services/FlowTester/ResponseValidator.php b/app/Services/FlowTester/ResponseValidator.php new file mode 100644 index 00000000..77216ecb --- /dev/null +++ b/app/Services/FlowTester/ResponseValidator.php @@ -0,0 +1,286 @@ + 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, '@')) { + 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 === '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).']'; + } + + /** + * 응답에서 값 추출 (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; + } +} diff --git a/app/Services/FlowTester/VariableBinder.php b/app/Services/FlowTester/VariableBinder.php new file mode 100644 index 00000000..4ea67f68 --- /dev/null +++ b/app/Services/FlowTester/VariableBinder.php @@ -0,0 +1,192 @@ +context = []; + } + + /** + * 전역 변수 설정 + */ + public function setVariables(array $variables): void + { + $this->context['variables'] = $variables; + } + + /** + * 컨텍스트에 변수 추가 + */ + public function setVariable(string $key, mixed $value): void + { + data_set($this->context, $key, $value); + } + + /** + * 단계 결과 저장 + */ + public function setStepResult(string $stepId, array $extracted, array $fullResponse): void + { + $this->context['steps'][$stepId] = [ + 'extracted' => $extracted, + 'response' => $fullResponse, + ]; + + // 편의를 위해 extracted 값을 stepId.key 형태로도 접근 가능하게 + foreach ($extracted as $key => $value) { + $this->context['steps'][$stepId][$key] = $value; + } + } + + /** + * 문자열/배열 내 모든 변수 치환 + */ + public function bind(mixed $input): mixed + { + if (is_string($input)) { + return $this->bindString($input); + } + + if (is_array($input)) { + return array_map(fn ($v) => $this->bind($v), $input); + } + + return $input; + } + + /** + * 문자열 내 변수 패턴 치환 + */ + private function bindString(string $input): string + { + // 내장 변수 처리 + $input = $this->resolveBuiltins($input); + + // 컨텍스트 변수 처리 + return preg_replace_callback( + '/\{\{([^}]+)\}\}/', + fn ($matches) => $this->resolveReference($matches[1]), + $input + ); + } + + /** + * 내장 변수 처리 ($timestamp, $uuid, $random:N) + */ + private function resolveBuiltins(string $input): string + { + // {{$timestamp}} → 현재 타임스탬프 + $input = str_replace('{{$timestamp}}', (string) time(), $input); + + // {{$uuid}} → 랜덤 UUID + $input = str_replace('{{$uuid}}', (string) Str::uuid(), $input); + + // {{$random:N}} → N자리 랜덤 숫자 + $input = preg_replace_callback( + '/\{\{\$random:(\d+)\}\}/', + function ($m) { + $digits = (int) $m[1]; + $max = (int) pow(10, $digits) - 1; + + return str_pad((string) random_int(0, $max), $digits, '0', STR_PAD_LEFT); + }, + $input + ); + + // {{$date}} → 현재 날짜 (Y-m-d) + $input = str_replace('{{$date}}', date('Y-m-d'), $input); + + // {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s) + $input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input); + + return $input; + } + + /** + * 참조 경로 해석 (step1.pageId → 실제 값) + */ + private function resolveReference(string $path): string + { + $path = trim($path); + + // variables.xxx → $this->context['variables']['xxx'] + if (str_starts_with($path, 'variables.')) { + $value = data_get($this->context, $path, ''); + + return $this->valueToString($value); + } + + // stepN.xxx → $this->context['steps']['stepN']['xxx'] + if (preg_match('/^(step\w+)\.(.+)$/', $path, $m)) { + $stepId = $m[1]; + $subPath = $m[2]; + + // stepN.response.xxx → 전체 응답에서 추출 + if (str_starts_with($subPath, 'response.')) { + $responsePath = substr($subPath, 9); // "response." 제거 + $value = data_get($this->context['steps'][$stepId]['response'] ?? [], $responsePath, ''); + + return $this->valueToString($value); + } + + // stepN.xxx → extracted 또는 직접 접근 + $value = data_get($this->context['steps'][$stepId] ?? [], $subPath, ''); + + return $this->valueToString($value); + } + + // 기타 경로는 context에서 직접 조회 + $value = data_get($this->context, $path, ''); + + return $this->valueToString($value); + } + + /** + * 값을 문자열로 변환 (배열/객체는 JSON) + */ + private function valueToString(mixed $value): string + { + if (is_null($value)) { + return ''; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_array($value) || is_object($value)) { + return json_encode($value, JSON_UNESCAPED_UNICODE); + } + + return (string) $value; + } + + /** + * 현재 컨텍스트 반환 (디버깅용) + */ + public function getContext(): array + { + return $this->context; + } +}