validateUrl($url); $startTime = microtime(true); try { $request = Http::timeout(config('api-explorer.proxy.timeout', 30)) ->withHeaders($headers); // 쿼리 파라미터 추가 if (! empty($query)) { $url = $this->appendQueryParams($url, $query); } // 요청 실행 $response = match (strtoupper($method)) { 'GET' => $request->get($url), 'POST' => $request->post($url, $body ?? []), 'PUT' => $request->put($url, $body ?? []), 'PATCH' => $request->patch($url, $body ?? []), 'DELETE' => $request->delete($url, $body ?? []), 'HEAD' => $request->head($url), 'OPTIONS' => $request->send('OPTIONS', $url), default => throw new \InvalidArgumentException("지원하지 않는 HTTP 메서드: {$method}"), }; $durationMs = (int) ((microtime(true) - $startTime) * 1000); return [ 'status' => $response->status(), 'headers' => $response->headers(), 'body' => $this->parseResponseBody($response), 'duration_ms' => $durationMs, ]; } catch (\Illuminate\Http\Client\ConnectionException $e) { $durationMs = (int) ((microtime(true) - $startTime) * 1000); return [ 'status' => 0, 'headers' => [], 'body' => [ 'error' => true, 'message' => '연결 실패: '.$e->getMessage(), ], 'duration_ms' => $durationMs, ]; } catch (\Exception $e) { $durationMs = (int) ((microtime(true) - $startTime) * 1000); return [ 'status' => 0, 'headers' => [], 'body' => [ 'error' => true, 'message' => '요청 오류: '.$e->getMessage(), ], 'duration_ms' => $durationMs, ]; } } /** * URL 유효성 검사 (화이트리스트) */ private function validateUrl(string $url): void { $allowedHosts = config('api-explorer.proxy.allowed_hosts', []); if (empty($allowedHosts)) { return; // 화이트리스트 미설정 시 모든 호스트 허용 } $parsedUrl = parse_url($url); $host = $parsedUrl['host'] ?? ''; if (! in_array($host, $allowedHosts)) { throw new \InvalidArgumentException("허용되지 않은 호스트: {$host}"); } } /** * 쿼리 파라미터 추가 */ private function appendQueryParams(string $url, array $query): string { // 빈 값 제거 $query = array_filter($query, fn ($v) => $v !== null && $v !== ''); if (empty($query)) { return $url; } $separator = str_contains($url, '?') ? '&' : '?'; return $url.$separator.http_build_query($query); } /** * 응답 본문 파싱 */ private function parseResponseBody($response): mixed { $contentType = $response->header('Content-Type') ?? ''; $body = $response->body(); // JSON 응답 if (str_contains($contentType, 'application/json')) { $decoded = json_decode($body, true); if (json_last_error() === JSON_ERROR_NONE) { return $decoded; } } // 텍스트 응답 (최대 크기 제한) $maxSize = config('api-explorer.proxy.max_body_size', 1024 * 1024); if (strlen($body) > $maxSize) { return [ 'truncated' => true, 'message' => '응답이 너무 큽니다. (최대 '.number_format($maxSize / 1024).'KB)', 'size' => strlen($body), ]; } return $body; } /** * 민감 헤더 마스킹 */ public function maskSensitiveHeaders(array $headers): array { $sensitiveHeaders = config('api-explorer.security.mask_sensitive_headers', []); $masked = []; foreach ($headers as $key => $value) { if (in_array($key, $sensitiveHeaders, true)) { $masked[$key] = '***MASKED***'; } else { $masked[$key] = $value; } } return $masked; } /** * 경로 파라미터 치환 */ public function substitutePathParams(string $path, array $params): string { foreach ($params as $key => $value) { $path = str_replace('{'.$key.'}', $value, $path); } return $path; } /** * 변수 치환 ({{VARIABLE}} 패턴) */ public function substituteVariables(string $text, array $variables): string { foreach ($variables as $key => $value) { $text = str_replace('{{'.$key.'}}', $value, $text); } return $text; } }