193 lines
5.5 KiB
PHP
193 lines
5.5 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\ApiExplorer;
|
||
|
|
|
||
|
|
use Illuminate\Support\Facades\Http;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* API 요청 프록시 서비스
|
||
|
|
*/
|
||
|
|
class ApiRequestService
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* API 요청 실행
|
||
|
|
*
|
||
|
|
* @return array{status: int, headers: array, body: mixed, duration_ms: int}
|
||
|
|
*/
|
||
|
|
public function execute(
|
||
|
|
string $method,
|
||
|
|
string $url,
|
||
|
|
array $headers = [],
|
||
|
|
array $query = [],
|
||
|
|
?array $body = null
|
||
|
|
): array {
|
||
|
|
// URL 유효성 검사
|
||
|
|
$this->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;
|
||
|
|
}
|
||
|
|
}
|