Files
sam-manage/app/Services/FlowTester/FlowExecutor.php
hskwon fe902472c1 feat: [flow-tester] 의존성 검사, Docker 지원, 인증 자동 주입 기능 추가
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 상태 스타일링 (노란색 배경/테두리)
- 행 클릭 시 스텝 상세 확장 기능
- 실행 결과 실시간 표시 개선
2025-11-27 22:20:36 +09:00

496 lines
15 KiB
PHP

<?php
namespace App\Services\FlowTester;
use Exception;
/**
* 플로우 실행 엔진
*
* 플로우 정의를 받아 단계별로 실행하고 결과를 반환합니다.
*
* 실행 흐름:
* 1. 플로우 정의 로드 및 검증
* 2. 의존성 기반 실행 순서 결정 (TopSort)
* 3. 변수 바인딩 초기화
* 4. 각 단계 순차 실행
* 5. 결과 수집 및 반환
*/
class FlowExecutor
{
private VariableBinder $binder;
private DependencyResolver $resolver;
private ResponseValidator $validator;
private HttpClient $httpClient;
/**
* 실행 로그
*/
private array $executionLog = [];
/**
* 실행 상태
*/
private string $status = 'PENDING';
/**
* 현재 진행 상황
*/
private int $completedSteps = 0;
private int $totalSteps = 0;
/**
* 스텝별 성공/실패 추적
*/
private array $stepSuccessMap = [];
public function __construct(
?VariableBinder $binder = null,
?DependencyResolver $resolver = null,
?ResponseValidator $validator = null,
?HttpClient $httpClient = null
) {
$this->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];
// 의존성 스텝 성공 여부 확인
$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++;
} 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());
}
}
/**
* 의존성 스텝 성공 여부 확인
*
* @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' => []];
}
/**
* 단일 스텝 실행
*/
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 - Docker 환경 자동 변환
if (isset($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
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']);
}
// 인증 - JSON에 없으면 .env에서 자동 로드
$apiKey = $config['apiKey'] ?? null;
if (empty($apiKey)) {
$apiKey = env('FLOW_TESTER_API_KEY');
} else {
// 플레이스홀더 치환 ({{$auth.apiKey}} 등)
$apiKey = $this->binder->bind($apiKey);
}
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');
}
/**
* 스텝 맵 구성 (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->stepSuccessMap = [];
$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;
}
}