feat: [flow-tester] API 로그 캡처 및 UI 개선

- ApiLogCapturer 추가: 플로우 실행 중 API 로그 캡처
- resolveBaseUrl() 추가: .env 환경변수 기반 baseUrl 지원
- 실행 상세 페이지: 스텝별 접기/펼치기 기능 (성공=접힘, 실패=펼침)
- JSON 가이드 및 예제 플로우 최신화
- AI 프롬프트 템플릿 업데이트
- bindExpectVariables() 추가: expect jsonPath 값에 변수 바인딩 적용
- areNumericEqual() 추가: 숫자 타입 유연 비교 ("2" == 2)
This commit is contained in:
2025-12-04 15:30:04 +09:00
parent 20cfa01579
commit fe10cae06c
9 changed files with 709 additions and 143 deletions

View File

@@ -22,7 +22,7 @@ class FlowTesterController extends Controller
public function index(): View
{
$flows = AdminApiFlow::with(['runs' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('updated_at')
->orderByDesc('created_at')
->paginate(20);
return view('dev-tools.flow-tester.index', compact('flows'));
@@ -231,6 +231,7 @@ public function run(int $id)
'failed_step' => $result['failedStep'],
'execution_log' => $result['executionLog'],
'error_message' => $result['errorMessage'],
'api_logs' => $result['apiLogs'] ?? [],
]);
return response()->json([

View File

@@ -53,6 +53,7 @@ class AdminApiFlowRun extends Model
'execution_log',
'input_variables',
'error_message',
'api_logs',
'executed_by',
];
@@ -67,6 +68,7 @@ class AdminApiFlowRun extends Model
'failed_step' => 'integer',
'execution_log' => 'array',
'input_variables' => 'array',
'api_logs' => 'array',
'executed_by' => 'integer',
];
@@ -171,4 +173,33 @@ public function getStatusColorAttribute(): string
default => 'bg-gray-100 text-gray-600',
};
}
/**
* API 로그 개수 반환
*/
public function getApiLogCountAttribute(): int
{
return is_array($this->api_logs) ? count($this->api_logs) : 0;
}
/**
* API 로그에 오류가 있는지 확인
*/
public function hasApiErrors(): bool
{
if (! is_array($this->api_logs)) {
return false;
}
foreach ($this->api_logs as $log) {
if (isset($log['type']) && $log['type'] === 'response') {
$status = $log['status'] ?? 0;
if ($status >= 400) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Services\FlowTester;
/**
* API 로그 캡처 서비스
*
* API 서버의 laravel.log에서 Request/Response 로그를 캡처합니다.
* 플로우 실행 시작/종료 시점의 파일 오프셋을 이용하여
* 해당 실행 중에 발생한 로그만 추출합니다.
*/
class ApiLogCapturer
{
private string $logPath;
private int $startOffset = 0;
public function __construct()
{
// API 로그 파일 경로 (Docker/로컬 환경 모두 지원)
$this->logPath = $this->resolveLogPath();
}
/**
* 캡처 시작 (현재 로그 파일 위치 기록)
*/
public function start(): void
{
if (file_exists($this->logPath)) {
$this->startOffset = filesize($this->logPath);
} else {
$this->startOffset = 0;
}
}
/**
* 캡처 종료 및 로그 추출
*
* @return array 파싱된 API 로그 배열
*/
public function capture(): array
{
if (! file_exists($this->logPath)) {
return [];
}
$currentSize = filesize($this->logPath);
// 새로운 로그가 없으면 빈 배열 반환
if ($currentSize <= $this->startOffset) {
return [];
}
// 새로 추가된 로그 읽기
$handle = fopen($this->logPath, 'r');
if (! $handle) {
return [];
}
fseek($handle, $this->startOffset);
$newLogs = fread($handle, $currentSize - $this->startOffset);
fclose($handle);
// Request/Response 로그만 파싱
return $this->parseLogs($newLogs);
}
/**
* API 로그 파일 경로 결정
*/
private function resolveLogPath(): string
{
// 1. 환경 변수로 지정된 경로
$envPath = env('API_LOG_PATH');
if ($envPath && file_exists($envPath)) {
return $envPath;
}
// 2. 로컬 개발 환경 (상대 경로)
$localPath = base_path('../api/storage/logs/laravel.log');
if (file_exists($localPath)) {
return $localPath;
}
// 3. Docker 환경 (절대 경로)
$dockerPath = '/var/www/api/storage/logs/laravel.log';
if (file_exists($dockerPath)) {
return $dockerPath;
}
// 4. 기본값 (로컬 경로)
return $localPath;
}
/**
* 로그 텍스트 파싱
*
* Laravel 로그 형식:
* [2025-12-04 12:30:53] local.INFO: API Request {...}
* [2025-12-04 12:30:53] local.INFO: API Response {...}
*/
private function parseLogs(string $rawLogs): array
{
$logs = [];
$pattern = '/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] \w+\.\w+: (API Request|API Response) (\{.+?\})(?=\s*\[\d{4}|\s*$)/s';
if (preg_match_all($pattern, $rawLogs, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$timestamp = $match[1];
$type = $match[2] === 'API Request' ? 'request' : 'response';
$jsonStr = $match[3];
// JSON 파싱
$data = json_decode($jsonStr, true);
if ($data === null) {
continue;
}
$log = [
'timestamp' => $timestamp,
'type' => $type,
];
if ($type === 'request') {
$log['method'] = $data['method'] ?? '';
$log['uri'] = $data['uri'] ?? '';
$log['input'] = $data['input'] ?? [];
$log['ip'] = $data['ip'] ?? '';
} else {
$log['uri'] = $data['uri'] ?? '';
$log['status'] = $data['status'] ?? 0;
// content는 JSON 문자열이므로 파싱
$content = $data['content'] ?? '';
if (is_string($content)) {
$parsedContent = json_decode($content, true);
if ($parsedContent !== null) {
$log['success'] = $parsedContent['success'] ?? null;
$log['message'] = $parsedContent['message'] ?? '';
// 에러 정보가 있으면 포함
if (isset($parsedContent['error'])) {
$log['error'] = $parsedContent['error'];
}
}
}
}
$logs[] = $log;
}
}
return $logs;
}
/**
* 로그 파일 경로 반환 (디버깅용)
*/
public function getLogPath(): string
{
return $this->logPath;
}
/**
* 로그 파일 존재 여부 확인
*/
public function isAvailable(): bool
{
return file_exists($this->logPath) && is_readable($this->logPath);
}
}

View File

@@ -26,6 +26,8 @@ class FlowExecutor
private HttpClient $httpClient;
private ApiLogCapturer $logCapturer;
/**
* 실행 로그
*/
@@ -48,16 +50,23 @@ class FlowExecutor
*/
private array $stepSuccessMap = [];
/**
* 캡처된 API 로그
*/
private array $apiLogs = [];
public function __construct(
?VariableBinder $binder = null,
?DependencyResolver $resolver = null,
?ResponseValidator $validator = null,
?HttpClient $httpClient = null
?HttpClient $httpClient = null,
?ApiLogCapturer $logCapturer = null
) {
$this->binder = $binder ?? new VariableBinder;
$this->resolver = $resolver ?? new DependencyResolver;
$this->validator = $validator ?? new ResponseValidator;
$this->httpClient = $httpClient ?? new HttpClient;
$this->logCapturer = $logCapturer ?? new ApiLogCapturer;
}
/**
@@ -72,6 +81,9 @@ public function execute(array $flowDefinition, array $inputVariables = []): arra
$startTime = microtime(true);
$this->reset();
// API 로그 캡처 시작
$this->logCapturer->start();
try {
// 1. 플로우 정의 검증
$this->validateFlowDefinition($flowDefinition);
@@ -242,6 +254,10 @@ private function executeStep(array $step): array
if (empty($expect) || ! isset($expect['status'])) {
$expect['status'] = [200, 201, 204];
}
// expect 값에도 변수 바인딩 적용 (jsonPath 값의 {{변수}} 치환)
$expect = $this->bindExpectVariables($expect);
$validation = $this->validator->validate($response, $expect);
// 6. 변수 추출
@@ -317,9 +333,10 @@ private function validateFlowDefinition(array $definition): void
*/
private function applyConfig(array $config): void
{
// Base URL - Docker 환경 자동 변환
if (isset($config['baseUrl'])) {
$baseUrl = $config['baseUrl'];
// Base URL 결정 - JSON에 있으면 사용, 없으면 .env에서
$baseUrl = $this->resolveBaseUrl($config['baseUrl'] ?? null);
if ($baseUrl) {
// Docker 환경에서 외부 URL을 내부 URL로 변환
if ($this->isDockerEnvironment()) {
@@ -395,6 +412,46 @@ private function getDefaultBearerToken(): ?string
return env('FLOW_TESTER_API_TOKEN');
}
/**
* Base URL 결정
*
* 우선순위:
* 1. JSON에 완전한 URL (도메인 포함) → 그대로 사용
* 2. JSON에 경로만 있거나 없음 → .env의 FLOW_TESTER_API_BASE_URL + 경로
*
* @param string|null $configBaseUrl JSON config의 baseUrl
* @return string|null 최종 Base URL
*/
private function resolveBaseUrl(?string $configBaseUrl): ?string
{
// 환경변수에서 기본 도메인 가져오기
$envBaseUrl = env('FLOW_TESTER_API_BASE_URL');
// JSON에 baseUrl이 없는 경우
if (empty($configBaseUrl)) {
return $envBaseUrl ?: null;
}
// JSON에 완전한 URL이 있는지 확인 (http:// 또는 https://로 시작)
if (preg_match('#^https?://#', $configBaseUrl)) {
// 완전한 URL이면 그대로 사용
return $configBaseUrl;
}
// 상대 경로만 있는 경우 (.env 도메인 + 상대 경로)
if ($envBaseUrl) {
// .env URL 끝의 슬래시 제거
$envBaseUrl = rtrim($envBaseUrl, '/');
// 상대 경로 시작의 슬래시 보장
$configBaseUrl = '/'.ltrim($configBaseUrl, '/');
return $envBaseUrl.$configBaseUrl;
}
// .env도 없고 상대 경로만 있으면 그대로 반환 (실패할 수 있음)
return $configBaseUrl;
}
/**
* Docker 환경인지 확인
*/
@@ -449,6 +506,9 @@ private function buildResult(
): array {
$duration = (int) ((microtime(true) - $startTime) * 1000);
// API 로그 캡처
$this->apiLogs = $this->logCapturer->capture();
return [
'status' => $status,
'duration' => $duration,
@@ -457,6 +517,7 @@ private function buildResult(
'failedStep' => $failedStep,
'errorMessage' => $errorMessage,
'executionLog' => $this->executionLog,
'apiLogs' => $this->apiLogs,
'startedAt' => date('Y-m-d H:i:s', (int) $startTime),
'completedAt' => date('Y-m-d H:i:s'),
];
@@ -472,6 +533,7 @@ private function reset(): void
$this->completedSteps = 0;
$this->totalSteps = 0;
$this->stepSuccessMap = [];
$this->apiLogs = [];
$this->binder->reset();
}
@@ -560,6 +622,27 @@ private function buildResultReason(bool $success, array $expect, int $actualStat
return $reason;
}
/**
* expect 값에 변수 바인딩 적용
*
* jsonPath 값들의 {{변수}} 플레이스홀더를 치환합니다.
* 예: "$.data.client_code": "{{test_client_code}}" → "TEST_CLIENT_123"
*/
private function bindExpectVariables(array $expect): array
{
// jsonPath 값들에 변수 바인딩 적용
if (isset($expect['jsonPath']) && is_array($expect['jsonPath'])) {
foreach ($expect['jsonPath'] as $path => $expected) {
// 문자열이고 {{}} 플레이스홀더가 있는 경우만 바인딩
if (is_string($expected) && str_contains($expected, '{{')) {
$expect['jsonPath'][$path] = $this->binder->bind($expected);
}
}
}
return $expect;
}
/**
* assertions 배열 형식을 expect 객체 형식으로 변환
*

View File

@@ -91,6 +91,12 @@ private function validateValue(mixed $actual, mixed $expected, string $path): ?s
{
// 직접 값 비교 (연산자가 아닌 경우)
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',
@@ -266,6 +272,30 @@ 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 처리)
*