Files
sam-manage/app/Services/ApiExplorer/ApiRequestService.php
hskwon fbd4fb728e feat: [api-explorer] Phase 1 기본 구조 및 OpenAPI 파싱 구현
- Config, Service, Controller, View 생성
- Model 4개 (admin_api_* 테이블 참조)
- 3-Panel 레이아웃 (sidebar, request, response)
- HTMX 기반 동적 UI
- 마이그레이션은 api/ 프로젝트에서 관리
2025-12-17 22:06:28 +09:00

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;
}
}