From fe902472c1463c7b740ec5934e73f233bbf619cb Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 27 Nov 2025 22:20:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[flow-tester]=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EA=B2=80=EC=82=AC,=20Docker=20=EC=A7=80=EC=9B=90,?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=9E=90=EB=8F=99=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FlowExecutor 개선: - 의존성 스텝 실패 시 후속 스텝 자동 스킵 로직 추가 - Docker 환경 자동 감지 및 내부 URL 변환 (api.sam.kr → nginx) - SSL 검증 비활성화 및 Host 헤더 설정 지원 - .env에서 API Key/Bearer Token 자동 주입 VariableBinder 개선: - 임의 stepId 패턴 지원 (page_create_1.tempPageId 등) - {{$env.VAR_NAME}} 환경변수 플레이스홀더 추가 - {{$auth.token}}, {{$auth.apiKey}} 인증 플레이스홀더 추가 UI 개선: - SKIPPED 상태 스타일링 (노란색 배경/테두리) - 행 클릭 시 스텝 상세 확장 기능 - 실행 결과 실시간 표시 개선 --- app/Services/FlowTester/FlowExecutor.php | 135 ++++++++- app/Services/FlowTester/HttpClient.php | 34 +++ app/Services/FlowTester/ResponseValidator.php | 2 +- app/Services/FlowTester/VariableBinder.php | 71 ++++- .../dev-tools/flow-tester/index.blade.php | 261 +++++++++++++++++- 5 files changed, 474 insertions(+), 29 deletions(-) diff --git a/app/Services/FlowTester/FlowExecutor.php b/app/Services/FlowTester/FlowExecutor.php index 58adca3e..fde7beee 100644 --- a/app/Services/FlowTester/FlowExecutor.php +++ b/app/Services/FlowTester/FlowExecutor.php @@ -43,6 +43,11 @@ class FlowExecutor private int $totalSteps = 0; + /** + * 스텝별 성공/실패 추적 + */ + private array $stepSuccessMap = []; + public function __construct( ?VariableBinder $binder = null, ?DependencyResolver $resolver = null, @@ -101,9 +106,26 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra foreach ($orderedStepIds as $stepId) { $step = $stepMap[$stepId]; + + // 의존성 스텝 성공 여부 확인 + $dependencyCheck = $this->checkDependencies($step); + if (! $dependencyCheck['canRun']) { + // 의존성 실패로 스킵 + $skipResult = $this->buildStepResult($stepId, $step['name'] ?? $stepId, microtime(true), false, [ + 'skipped' => true, + 'skipReason' => $dependencyCheck['reason'], + 'failedDependencies' => $dependencyCheck['failedDeps'], + ]); + $this->executionLog[] = $skipResult; + $this->stepSuccessMap[$stepId] = false; + + continue; + } + $stepResult = $this->executeStep($step); $this->executionLog[] = $stepResult; + $this->stepSuccessMap[$stepId] = $stepResult['success']; if ($stepResult['success']) { $this->completedSteps++; @@ -133,6 +155,42 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra } } + /** + * 의존성 스텝 성공 여부 확인 + * + * @param array $step 실행할 스텝 + * @return array ['canRun' => bool, 'reason' => string|null, 'failedDeps' => array] + */ + private function checkDependencies(array $step): array + { + $dependencies = $step['dependsOn'] ?? []; + + if (empty($dependencies)) { + return ['canRun' => true, 'reason' => null, 'failedDeps' => []]; + } + + $failedDeps = []; + + foreach ($dependencies as $depId) { + // 의존성 스텝이 실행되지 않았거나 실패한 경우 + if (! isset($this->stepSuccessMap[$depId])) { + $failedDeps[] = $depId.' (not executed)'; + } elseif (! $this->stepSuccessMap[$depId]) { + $failedDeps[] = $depId.' (failed)'; + } + } + + if (! empty($failedDeps)) { + return [ + 'canRun' => false, + 'reason' => 'Dependency failed: '.implode(', ', $failedDeps), + 'failedDeps' => $failedDeps, + ]; + } + + return ['canRun' => true, 'reason' => null, 'failedDeps' => []]; + } + /** * 단일 스텝 실행 */ @@ -246,9 +304,31 @@ private function validateFlowDefinition(array $definition): void */ private function applyConfig(array $config): void { - // Base URL + // Base URL - Docker 환경 자동 변환 if (isset($config['baseUrl'])) { - $this->httpClient->setBaseUrl($config['baseUrl']); + $baseUrl = $config['baseUrl']; + + // Docker 환경에서 외부 URL을 내부 URL로 변환 + if ($this->isDockerEnvironment()) { + $parsedUrl = parse_url($baseUrl); + $host = $parsedUrl['host'] ?? ''; + + // *.sam.kr 도메인을 nginx 컨테이너로 라우팅 + if (str_ends_with($host, '.sam.kr') || $host === 'sam.kr') { + $this->httpClient->setHostHeader($host); + $this->httpClient->withoutVerifying(); + + // URL을 https://nginx/... 로 변환 + $internalUrl = preg_replace( + '#https?://[^/]+#', + 'https://nginx', + $baseUrl + ); + $baseUrl = $internalUrl; + } + } + + $this->httpClient->setBaseUrl($baseUrl); } // Timeout @@ -262,13 +342,53 @@ private function applyConfig(array $config): void $this->httpClient->setDefaultHeaders($config['headers']); } - // 인증 - if (isset($config['apiKey'])) { - $this->httpClient->setApiKey($config['apiKey']); + // 인증 - JSON에 없으면 .env에서 자동 로드 + $apiKey = $config['apiKey'] ?? null; + if (empty($apiKey)) { + $apiKey = env('FLOW_TESTER_API_KEY'); + } else { + // 플레이스홀더 치환 ({{$auth.apiKey}} 등) + $apiKey = $this->binder->bind($apiKey); } - if (isset($config['bearerToken'])) { - $this->httpClient->setBearerToken($config['bearerToken']); + if (! empty($apiKey)) { + $this->httpClient->setApiKey($apiKey); } + + $bearerToken = $config['bearerToken'] ?? null; + if (empty($bearerToken)) { + // .env 또는 로그인 사용자의 토큰 사용 + $bearerToken = $this->getDefaultBearerToken(); + } else { + // 플레이스홀더 치환 ({{$auth.token}} 등) + $bearerToken = $this->binder->bind($bearerToken); + } + if (! empty($bearerToken)) { + $this->httpClient->setBearerToken($bearerToken); + } + } + + /** + * 기본 Bearer 토큰 조회 + * 우선순위: 사용자 api_token → .env FLOW_TESTER_API_TOKEN + */ + private function getDefaultBearerToken(): ?string + { + $user = auth()->user(); + + if ($user && ! empty($user->api_token)) { + return $user->api_token; + } + + return env('FLOW_TESTER_API_TOKEN'); + } + + /** + * Docker 환경인지 확인 + */ + private function isDockerEnvironment(): bool + { + // Docker 컨테이너 내부에서 실행 중인지 확인 + return file_exists('/.dockerenv') || (getenv('DOCKER_CONTAINER') === 'true'); } /** @@ -338,6 +458,7 @@ private function reset(): void $this->status = 'PENDING'; $this->completedSteps = 0; $this->totalSteps = 0; + $this->stepSuccessMap = []; $this->binder->reset(); } diff --git a/app/Services/FlowTester/HttpClient.php b/app/Services/FlowTester/HttpClient.php index b0f702c8..35fe7e0b 100644 --- a/app/Services/FlowTester/HttpClient.php +++ b/app/Services/FlowTester/HttpClient.php @@ -22,6 +22,30 @@ class HttpClient private ?string $bearerToken = null; + private bool $verifySsl = true; + + private ?string $hostHeader = null; + + /** + * SSL 검증 비활성화 (Docker 내부 통신용) + */ + public function withoutVerifying(): self + { + $this->verifySsl = false; + + return $this; + } + + /** + * Host 헤더 설정 (Docker 내부 통신용) + */ + public function setHostHeader(string $host): self + { + $this->hostHeader = $host; + + return $this; + } + /** * 기본 URL 설정 */ @@ -91,6 +115,16 @@ public function request(string $method, string $endpoint, array $options = []): $request = Http::timeout($this->timeout) ->withHeaders($headers); + // SSL 검증 비활성화 (Docker 내부 통신용) + if (! $this->verifySsl) { + $request = $request->withoutVerifying(); + } + + // Host 헤더 추가 (Docker 내부 통신용) + if ($this->hostHeader) { + $request = $request->withHeaders(['Host' => $this->hostHeader]); + } + // API 키 추가 if ($this->apiKey) { $request = $request->withHeaders(['X-API-KEY' => $this->apiKey]); diff --git a/app/Services/FlowTester/ResponseValidator.php b/app/Services/FlowTester/ResponseValidator.php index 77216ecb..e96d60f3 100644 --- a/app/Services/FlowTester/ResponseValidator.php +++ b/app/Services/FlowTester/ResponseValidator.php @@ -93,7 +93,7 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s if (! is_string($expected) || ! str_starts_with($expected, '@')) { if ($actual !== $expected) { return sprintf( - "Path %s: expected %s, got %s", + 'Path %s: expected %s, got %s', $path, json_encode($expected, JSON_UNESCAPED_UNICODE), json_encode($actual, JSON_UNESCAPED_UNICODE) diff --git a/app/Services/FlowTester/VariableBinder.php b/app/Services/FlowTester/VariableBinder.php index 4ea67f68..22ef49af 100644 --- a/app/Services/FlowTester/VariableBinder.php +++ b/app/Services/FlowTester/VariableBinder.php @@ -92,7 +92,7 @@ private function bindString(string $input): string } /** - * 내장 변수 처리 ($timestamp, $uuid, $random:N) + * 내장 변수 처리 ($timestamp, $uuid, $random:N, $env.XXX, $auth.token) */ private function resolveBuiltins(string $input): string { @@ -120,11 +120,47 @@ function ($m) { // {{$datetime}} → 현재 날짜시간 (Y-m-d H:i:s) $input = str_replace('{{$datetime}}', date('Y-m-d H:i:s'), $input); + // {{$env.VAR_NAME}} → 환경변수에서 읽기 + $input = preg_replace_callback( + '/\{\{\$env\.([A-Z_][A-Z0-9_]*)\}\}/i', + fn ($m) => env($m[1], ''), + $input + ); + + // {{$auth.token}} → 현재 로그인 사용자의 API 토큰 + if (str_contains($input, '{{$auth.token}}')) { + $token = $this->getAuthToken(); + $input = str_replace('{{$auth.token}}', $token, $input); + } + + // {{$auth.apiKey}} → .env의 API Key + $input = str_replace('{{$auth.apiKey}}', env('FLOW_TESTER_API_KEY', ''), $input); + return $input; } /** - * 참조 경로 해석 (step1.pageId → 실제 값) + * 현재 로그인 사용자의 API 토큰 조회 + */ + private function getAuthToken(): string + { + $user = auth()->user(); + + if (! $user) { + return env('FLOW_TESTER_API_TOKEN', ''); + } + + // 사용자에게 저장된 API 토큰이 있으면 사용 + if (! empty($user->api_token)) { + return $user->api_token; + } + + // 없으면 .env의 기본 토큰 사용 + return env('FLOW_TESTER_API_TOKEN', ''); + } + + /** + * 참조 경로 해석 (step1.pageId 또는 page_create_1.pageId → 실제 값) */ private function resolveReference(string $path): string { @@ -137,23 +173,28 @@ private function resolveReference(string $path): string return $this->valueToString($value); } - // stepN.xxx → $this->context['steps']['stepN']['xxx'] - if (preg_match('/^(step\w+)\.(.+)$/', $path, $m)) { - $stepId = $m[1]; - $subPath = $m[2]; + // stepId.xxx 패턴 감지 (stepId는 등록된 step의 ID) + // 첫 번째 점(.) 기준으로 분리 + $dotPos = strpos($path, '.'); + if ($dotPos !== false) { + $potentialStepId = substr($path, 0, $dotPos); + $subPath = substr($path, $dotPos + 1); - // stepN.response.xxx → 전체 응답에서 추출 - if (str_starts_with($subPath, 'response.')) { - $responsePath = substr($subPath, 9); // "response." 제거 - $value = data_get($this->context['steps'][$stepId]['response'] ?? [], $responsePath, ''); + // 등록된 step인지 확인 + if (isset($this->context['steps'][$potentialStepId])) { + // stepId.response.xxx → 전체 응답에서 추출 + if (str_starts_with($subPath, 'response.')) { + $responsePath = substr($subPath, 9); // "response." 제거 + $value = data_get($this->context['steps'][$potentialStepId]['response'] ?? [], $responsePath, ''); + + return $this->valueToString($value); + } + + // stepId.xxx → extracted 또는 직접 접근 + $value = data_get($this->context['steps'][$potentialStepId] ?? [], $subPath, ''); return $this->valueToString($value); } - - // stepN.xxx → extracted 또는 직접 접근 - $value = data_get($this->context['steps'][$stepId] ?? [], $subPath, ''); - - return $this->valueToString($value); } // 기타 경로는 context에서 직접 조회 diff --git a/resources/views/dev-tools/flow-tester/index.blade.php b/resources/views/dev-tools/flow-tester/index.blade.php index d45f0e3b..d15afb0a 100644 --- a/resources/views/dev-tools/flow-tester/index.blade.php +++ b/resources/views/dev-tools/flow-tester/index.blade.php @@ -90,13 +90,22 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc @foreach($flows as $flow) @php $latestRun = $flow->runs->first(); + $steps = $flow->flow_definition['steps'] ?? []; @endphp - + {{-- 메인 row --}} + -
{{ $flow->name }}
- @if($flow->description) -
{{ $flow->description }}
- @endif +
+ + + +
+
{{ $flow->name }}
+ @if($flow->description) +
{{ $flow->description }}
+ @endif +
+
@if($flow->category) @@ -124,7 +133,7 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc 대기 @endif - +