'123', 'slug' => 'clothes']) * @param string $locale 로케일 * @param array $queryParams URL 쿼리 파라미터 (예: ['page' => '2', 'sort' => 'latest']) * @param array $extraContext 엔드포인트/params 표현식 평가에 사용할 추가 컨텍스트 (_global 등) * @return array data_source ID → 응답 데이터 매핑 */ public function resolve(array $dataSources, array $seoDataSourceIds, array $routeParams, string $locale, array $queryParams = [], array $extraContext = []): array { $context = []; $evalContext = array_merge($extraContext, [ 'route' => array_merge($extraContext['route'] ?? [], $routeParams), 'query' => $queryParams, ]); foreach ($dataSources as $ds) { $dsId = $ds['id'] ?? ''; // seo.data_sources에 명시된 ID만 호출 if (! in_array($dsId, $seoDataSourceIds)) { continue; } $endpoint = $ds['endpoint'] ?? ''; if ($endpoint === '') { continue; } // 엔드포인트 표현식 치환 ({{route.id}}, {{query.q}}, {{_global.xxx}}) $endpoint = $this->resolveEndpoint($endpoint, $evalContext); // params 해석 ({{query.xxx}}, {{route.xxx}} 표현식 치환) $resolvedParams = $this->resolveParams($ds['params'] ?? [], $routeParams, $queryParams, $extraContext); // API 호출 $data = $this->callApi($endpoint, $ds, $locale, $resolvedParams); $context[$dsId] = $data; } return $context; } /** * 엔드포인트의 표현식을 치환합니다. * * `{{route.xxx}}` 뿐 아니라 `{{query.xxx}}` / `{{_global.xxx}}` 등 컨텍스트 표현식을 * 모두 해석합니다 — 검색 결과처럼 엔드포인트 문자열 안에서 쿼리·전역 상태를 보간하는 * 데이터소스가 있기 때문입니다. 미해석 시 표현식이 URL에 그대로 남아 호출이 실패합니다. * * @param string $endpoint 원본 엔드포인트 * @param array $evalContext 평가 컨텍스트 (route/query/_global 등) * @return string 치환된 엔드포인트 */ private function resolveEndpoint(string $endpoint, array $evalContext): string { if (! str_contains($endpoint, '{{')) { return $endpoint; } return app(ExpressionEvaluator::class)->evaluate($endpoint, $evalContext); } /** * 데이터소스 params의 표현식을 해석합니다. * * {{query.xxx}} → 쿼리 파라미터, {{route.xxx}} → 라우트 파라미터, * 숫자/문자열 리터럴 → 그대로 유지합니다. * * @param array $params 원본 params 정의 * @param array $routeParams 라우트 파라미터 * @param array $queryParams 쿼리 파라미터 * @param array $extraContext 추가 평가 컨텍스트 (_global 등) * @return array 해석된 params (키 → 값) */ private function resolveParams(array $params, array $routeParams, array $queryParams, array $extraContext = []): array { if (empty($params)) { return []; } $resolved = []; $evalContext = array_merge($extraContext, [ 'route' => $routeParams, 'query' => $queryParams, ]); $evaluator = app(ExpressionEvaluator::class); foreach ($params as $key => $value) { if (is_numeric($value)) { $resolved[$key] = $value; continue; } $resolvedValue = $evaluator->evaluate((string) $value, $evalContext); // 빈 문자열은 전달하지 않음 (선택적 파라미터) if ($resolvedValue !== '') { $resolved[$key] = $resolvedValue; } } return $resolved; } /** * 내부 API를 호출합니다. * * @param string $endpoint API 엔드포인트 * @param array $ds data_source 정의 * @param string $locale 로케일 * @param array $resolvedParams 해석된 쿼리 파라미터 * @return array 응답 데이터 */ private function callApi(string $endpoint, array $ds, string $locale, array $resolvedParams = []): array { $method = strtolower($ds['method'] ?? 'GET'); $fallback = $ds['fallback'] ?? []; $timeout = 5; try { $fullUrl = url($endpoint); $request = Http::timeout($timeout) ->withoutVerifying() ->withHeaders([ 'Accept' => 'application/json', 'Accept-Language' => $locale, ]); // GET 요청 시 쿼리 파라미터 전달 if ($method === 'get' && ! empty($resolvedParams)) { $fullUrl .= '?'.http_build_query($resolvedParams); } $response = $request->$method($fullUrl); if ($response->successful()) { return $response->json() ?? $fallback; } Log::warning('[SEO] DataSource API failed', [ 'endpoint' => $endpoint, 'status' => $response->status(), ]); return $fallback; } catch (\Throwable $e) { Log::warning('[SEO] DataSource API error', [ 'endpoint' => $endpoint, 'error' => $e->getMessage(), ]); return $fallback; } } }