feat: [flow-tester] API 로그 캡처 및 UI 개선
- ApiLogCapturer 추가: 플로우 실행 중 API 로그 캡처
- resolveBaseUrl() 추가: .env 환경변수 기반 baseUrl 지원
- 실행 상세 페이지: 스텝별 접기/펼치기 기능 (성공=접힘, 실패=펼침)
- JSON 가이드 및 예제 플로우 최신화
- AI 프롬프트 템플릿 업데이트
- bindExpectVariables() 추가: expect jsonPath 값에 변수 바인딩 적용
- areNumericEqual() 추가: 숫자 타입 유연 비교 ("2" == 2)
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
171
app/Services/FlowTester/ApiLogCapturer.php
Normal file
171
app/Services/FlowTester/ApiLogCapturer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 객체 형식으로 변환
|
||||
*
|
||||
|
||||
@@ -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 처리)
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user