표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이 갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상 대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다. 판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은 회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다. - 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 / _computed stale 전파 / 원본 타입 소실) - 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설 - 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인 - BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환 - iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬 - 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임 - 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지 못하던 결함 수정 - SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다 - audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소, 신규 룰 5종 등재
2039 lines
73 KiB
TypeScript
2039 lines
73 KiB
TypeScript
/**
|
|
* NOTE: @since versions (engine-v1.x.x) refer to internal template engine
|
|
* development iterations, not G7 platform release versions.
|
|
*/
|
|
/**
|
|
* 그누보드7 템플릿 엔진 - 데이터 소스 매니저
|
|
*
|
|
* data_sources를 처리하고 API 요청을 관리하는 매니저
|
|
*
|
|
* @package G7
|
|
* @subpackage Template Engine
|
|
* @since 1.0.0
|
|
*/
|
|
|
|
import { getApiClient } from '../api/ApiClient';
|
|
import { AuthManager } from '../auth/AuthManager';
|
|
import { resolveExpressionString, evaluateRenderCondition } from './helpers/RenderHelpers';
|
|
import { DataBindingEngine } from './DataBindingEngine';
|
|
import { extractSingleBinding } from './BindingShape';
|
|
import { hasPipes } from './PipeRegistry';
|
|
import { getErrorHandlingResolver } from '../error';
|
|
import { getActionDispatcher } from './ActionDispatcher';
|
|
import type { ErrorHandlingMap, OnErrorHandler, OnSuccessHandler, ErrorContext, SuccessContext, ErrorCondition } from '../types/ErrorHandling';
|
|
import { createLogger } from '../utils/Logger';
|
|
import { webSocketManager } from '../websocket/WebSocketManager';
|
|
import type { G7DevToolsInterface } from './G7CoreGlobals';
|
|
import type { GlobalHeaderRule } from './LayoutLoader';
|
|
|
|
const logger = createLogger('DataSourceManager');
|
|
|
|
// DataBindingEngine 인스턴스 (표현식 평가용)
|
|
const bindingEngine = new DataBindingEngine();
|
|
|
|
/**
|
|
* 파라미터 값을 해석합니다. 배열 등 원본 타입을 유지합니다.
|
|
*
|
|
* resolveExpressionString은 항상 문자열을 반환하므로,
|
|
* 전체가 {{...}} 표현식인 경우 evaluateExpression을 사용하여
|
|
* 배열, 객체 등 원본 타입을 유지합니다.
|
|
*
|
|
* @param value 파라미터 값 (문자열)
|
|
* @param context 바인딩 컨텍스트
|
|
* @returns 해석된 값 (원본 타입 유지)
|
|
*/
|
|
function resolveParamValue(value: string, context: Record<string, any>): any {
|
|
// 전체가 하나의 {{...}} 표현식인지 확인
|
|
// 예: "{{query['sales_status[]']}}" → true
|
|
// 예: "page={{query.page}}" → false
|
|
//
|
|
// 판정은 BindingShape 정본을 쓴다. 종전 greedy 정규식 `^\{\{(.+)\}\}$` 은
|
|
// `"{{a}}-{{b}}"` 같은 보간 문자열도 단일 표현식으로 오판했고, 그때 잘라낸
|
|
// `a}}-{{b` 를 평가하다 예외가 나면 **원본 문자열이 그대로 서버로 전송**됐다.
|
|
// @since engine-v1.55.0
|
|
const expression = extractSingleBinding(value);
|
|
|
|
if (expression !== null) {
|
|
// 단일 표현식인 경우 evaluateExpression으로 원본 타입 유지
|
|
try {
|
|
// 파이프 표현식은 evaluatePipeExpression 으로 평가한다 — evaluateExpression 은
|
|
// `|` 를 JS 비트 OR 로 보므로 인자 있는 파이프는 예외로 아래 catch 에 걸려
|
|
// 원본 `{{...}}` 리터럴이 그대로 쿼리스트링에 실려 서버로 전송된다.
|
|
// @since engine-v1.54.10
|
|
return hasPipes(expression)
|
|
? bindingEngine.evaluatePipeExpression(expression, context, { skipCache: true })
|
|
: bindingEngine.evaluateExpression(expression, context);
|
|
} catch (error) {
|
|
logger.error('Failed to evaluate param expression:', expression, error);
|
|
return value; // 실패 시 원본 반환
|
|
}
|
|
}
|
|
|
|
// 복합 문자열인 경우 resolveExpressionString 사용 (문자열 보간)
|
|
return resolveExpressionString(value, context, { skipCache: true });
|
|
}
|
|
|
|
/**
|
|
* URLSearchParams를 배열 파라미터를 지원하는 객체로 변환
|
|
*
|
|
* URLSearchParams.entries()와 Object.fromEntries()를 사용하면
|
|
* 중복 키(배열 파라미터)의 마지막 값만 남게 됩니다.
|
|
* 이 함수는 getAll()을 사용하여 모든 값을 배열로 수집합니다.
|
|
*
|
|
* @param params URLSearchParams 인스턴스
|
|
* @returns 배열 파라미터를 지원하는 객체
|
|
*
|
|
* @example
|
|
* // sales_status[]=a&sales_status[]=b → { 'sales_status[]': ['a', 'b'] }
|
|
* // page=1&sort=name → { page: '1', sort: 'name' }
|
|
*/
|
|
function parseQueryParams(params: URLSearchParams): Record<string, string | string[]> {
|
|
const result: Record<string, string | string[]> = {};
|
|
|
|
for (const key of params.keys()) {
|
|
// 이미 처리한 키는 건너뛰기
|
|
if (key in result) continue;
|
|
|
|
const values = params.getAll(key);
|
|
if (values.length > 1) {
|
|
// 여러 값이 있으면 배열로 저장
|
|
result[key] = values;
|
|
} else if (values.length === 1) {
|
|
// 단일 값이지만 key[] 형태면 배열로 저장 (일관성)
|
|
if (key.endsWith('[]')) {
|
|
result[key] = values;
|
|
} else {
|
|
result[key] = values[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* G7Core.devTools 인터페이스 가져오기
|
|
*
|
|
* G7DevToolsCore.getInstance() 직접 호출 대신 G7Core.devTools를 사용합니다.
|
|
* DevTools가 비활성화되거나 초기화되지 않은 경우 안전하게 undefined 반환
|
|
*/
|
|
function getDevTools(): G7DevToolsInterface | undefined {
|
|
try {
|
|
const G7Core = (window as any).G7Core;
|
|
return G7Core?.devTools;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 타입
|
|
*/
|
|
export type DataSourceType = 'api' | 'static' | 'route_params' | 'query_params' | 'websocket';
|
|
|
|
/**
|
|
* WebSocket 채널 타입
|
|
*/
|
|
export type WebSocketChannelType = 'public' | 'private' | 'presence';
|
|
|
|
/**
|
|
* HTTP 메서드 타입
|
|
*/
|
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
|
|
/**
|
|
* 로딩 전략 타입
|
|
*
|
|
* - blocking: API 응답 완료 전까지 렌더링하지 않음
|
|
* - progressive: 스켈레톤 UI 렌더링 후 데이터 로드되면 업데이트 (기본값)
|
|
* - background: 일단 렌더링하고 백그라운드에서 fetch
|
|
*/
|
|
export type LoadingStrategy = 'blocking' | 'progressive' | 'background';
|
|
|
|
/**
|
|
* 데이터 소스 로딩 상태
|
|
*/
|
|
export type DataSourceLoadingState = 'idle' | 'loading' | 'success' | 'error';
|
|
|
|
/**
|
|
* 데이터 소스 인터페이스
|
|
*/
|
|
export interface DataSource {
|
|
/**
|
|
* 데이터 소스 고유 ID
|
|
*/
|
|
id: string;
|
|
|
|
/**
|
|
* 데이터 소스 타입
|
|
*/
|
|
type: DataSourceType;
|
|
|
|
/**
|
|
* API 엔드포인트 (type이 'api'인 경우)
|
|
*/
|
|
endpoint?: string;
|
|
|
|
/**
|
|
* HTTP 메서드 (type이 'api'인 경우)
|
|
*/
|
|
method?: HttpMethod;
|
|
|
|
/**
|
|
* 자동 fetch 여부
|
|
*/
|
|
auto_fetch?: boolean;
|
|
|
|
/**
|
|
* 인증 필요 여부 (deprecated: auth_mode 사용 권장)
|
|
*
|
|
* @deprecated auth_mode를 사용하세요. auth_required: true는 auth_mode: 'required'와 동일합니다.
|
|
*/
|
|
auth_required?: boolean;
|
|
|
|
/**
|
|
* 인증 모드
|
|
*
|
|
* - 'required': 토큰 필수 (ApiClient 사용, 토큰 없으면 요청 실패)
|
|
* - 'optional': 토큰 있으면 전송, 없으면 일반 요청 (비회원/회원 공용 API)
|
|
* - 'none': 토큰 안 보냄 (공개 API, 기본값)
|
|
*
|
|
* auth_required: true는 auth_mode: 'required'와 동일하게 동작합니다.
|
|
*/
|
|
auth_mode?: 'required' | 'optional' | 'none';
|
|
|
|
/**
|
|
* 로딩 전략 (기본값: progressive)
|
|
*/
|
|
loading_strategy?: LoadingStrategy;
|
|
|
|
/**
|
|
* 요청 Content-Type (기본값: 'application/json')
|
|
*
|
|
* 'multipart/form-data'로 설정하면 파일 업로드가 가능합니다.
|
|
* File/Blob 객체를 params에 포함하여 FormData로 전송합니다.
|
|
*
|
|
* @example
|
|
* { "id": "upload", "endpoint": "/api/upload", "method": "POST",
|
|
* "contentType": "multipart/form-data", "params": { "file": "{{_global.uploadFile}}" } }
|
|
*/
|
|
contentType?: string;
|
|
|
|
/**
|
|
* 요청 파라미터
|
|
*/
|
|
params?: Record<string, any>;
|
|
|
|
/**
|
|
* 정적 데이터 (type이 'static'인 경우)
|
|
*/
|
|
data?: any;
|
|
|
|
/**
|
|
* 로컬 상태 초기화 설정
|
|
*
|
|
* API 응답 데이터를 _local[key]에 자동 복사합니다.
|
|
* 폼 편집 시 초기 데이터를 로컬 상태로 관리할 때 사용합니다.
|
|
*
|
|
* 문자열 형태: 전체 응답 데이터를 저장
|
|
* 객체 형태: path를 지정하여 응답의 특정 필드만 저장
|
|
*
|
|
* @example
|
|
* // 문자열 형태 - 전체 응답 저장
|
|
* { "id": "user", "endpoint": "/api/users/1", "initLocal": "form" }
|
|
* // → response.data가 _local.form에 복사됨
|
|
*
|
|
* @example
|
|
* // 객체 형태 - 특정 필드만 저장
|
|
* { "id": "user", "endpoint": "/api/users/1", "initLocal": { "key": "form", "path": "data" } }
|
|
* // → response.data.data가 _local.form에 복사됨
|
|
*
|
|
* @example
|
|
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
|
|
* { "id": "cart", "endpoint": "/api/cart", "initLocal": { "selectedItems": "data.item_ids" } }
|
|
* // → response.data.data.item_ids가 _local.selectedItems에 복사됨
|
|
*
|
|
* @example
|
|
* // 맵 형태 + 표현식 (engine-v1.7.0+)
|
|
* { "id": "cart", "initLocal": { "selectedItems": "{{data.items.map(i => i.id)}}" } }
|
|
* // → response.data.data.items를 map하여 _local.selectedItems에 저장
|
|
*
|
|
* @example
|
|
* // dot notation 타겟 경로 (engine-v1.8.0+)
|
|
* { "id": "checkout", "initLocal": { "checkout.item_coupons": "data.promotions.item_coupons" } }
|
|
* // → response.data.data.promotions.item_coupons가 _local.checkout.item_coupons에 복사됨
|
|
*
|
|
* @example
|
|
* // 중첩 객체 표기법 (engine-v1.8.0+)
|
|
* { "id": "checkout", "initLocal": {
|
|
* "checkout": {
|
|
* "item_coupons": "data.promotions.item_coupons",
|
|
* "use_points": "data.use_points"
|
|
* }
|
|
* }
|
|
* }
|
|
*
|
|
* @example
|
|
* // 병합 전략 옵션 (engine-v1.8.0+) - _merge: "deep" | "shallow" | "replace"
|
|
* { "id": "checkout", "initLocal": {
|
|
* "_merge": "replace",
|
|
* "checkout.item_coupons": "data.promotions.item_coupons"
|
|
* }
|
|
* }
|
|
*/
|
|
initLocal?: string | { key: string; path: string } | Record<string, string | Record<string, string>>;
|
|
|
|
/**
|
|
* 전역 상태 초기화 설정
|
|
*
|
|
* API 응답 데이터를 _global[key]에 자동 복사합니다.
|
|
* 여러 페이지에서 공유하는 데이터(현재 사용자, 앱 설정 등)에 사용합니다.
|
|
*
|
|
* 문자열 형태: 전체 응답 데이터를 저장
|
|
* 객체 형태: path를 지정하여 응답의 특정 필드만 저장
|
|
*
|
|
* @example
|
|
* // 문자열 형태 - 전체 응답 저장
|
|
* { "id": "currentUser", "endpoint": "/api/me", "initGlobal": "currentUser" }
|
|
* // → response.data가 _global.currentUser에 복사됨
|
|
*
|
|
* @example
|
|
* // 객체 형태 - 특정 필드만 저장
|
|
* { "id": "modules", "endpoint": "/api/modules", "initGlobal": { "key": "installedModules", "path": "data" } }
|
|
* // → response.data.data가 _global.installedModules에 복사됨
|
|
*
|
|
* @example
|
|
* // 배열 형태 - 여러 전역 상태 동시 초기화
|
|
* { "id": "layout_files", "endpoint": "/api/layouts", "initGlobal": [
|
|
* "layoutFilesList",
|
|
* { "key": "selectedLayoutName", "path": "[0].name" }
|
|
* ] }
|
|
* // → response.data가 _global.layoutFilesList에 복사됨
|
|
* // → response.data[0].name이 _global.selectedLayoutName에 복사됨
|
|
*
|
|
* @example
|
|
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
|
|
* { "id": "config", "endpoint": "/api/config", "initGlobal": { "theme": "data.theme", "locale": "data.locale" } }
|
|
* // → response.data.data.theme이 _global.theme에, response.data.data.locale이 _global.locale에 복사됨
|
|
*/
|
|
initGlobal?: string | { key: string; path: string } | Array<string | { key: string; path: string }> | Record<string, string>;
|
|
|
|
/**
|
|
* 격리된 상태 초기화 설정
|
|
*
|
|
* API 응답 데이터를 _isolated[key]에 자동 복사합니다.
|
|
* isolatedState 속성이 정의된 컴포넌트 내에서만 사용됩니다.
|
|
* 해당 영역 내에서만 유효한 독립적인 상태가 필요할 때 사용합니다.
|
|
*
|
|
* 문자열 형태: 전체 응답 데이터를 저장
|
|
* 객체 형태: { key: 저장할 키, path: 응답에서 추출할 경로 }
|
|
*
|
|
* @example
|
|
* // 문자열 형태 - 전체 응답 저장
|
|
* { "id": "categories", "endpoint": "/api/categories", "initIsolated": "categoryList" }
|
|
* // → response.data가 _isolated.categoryList에 복사됨
|
|
*
|
|
* @example
|
|
* // 객체 형태 - 특정 필드만 저장
|
|
* { "id": "options", "endpoint": "/api/options", "initIsolated": { "key": "items", "path": "data.list" } }
|
|
* // → response.data.data.list가 _isolated.items에 복사됨
|
|
*
|
|
* @example
|
|
* // 맵 형태 (engine-v1.7.0+) - 여러 필드를 각각 저장 (표현식 지원)
|
|
* { "id": "widget", "endpoint": "/api/widget", "initIsolated": { "items": "data.list", "total": "data.count" } }
|
|
* // → response.data.data.list가 _isolated.items에, data.count가 _isolated.total에 복사됨
|
|
*/
|
|
initIsolated?: string | { key: string; path: string } | Record<string, string>;
|
|
|
|
/**
|
|
* 마운트 시 다시 fetch 여부
|
|
*
|
|
* true로 설정하면 페이지에 진입할 때마다 데이터를 다시 fetch하고
|
|
* initLocal/initGlobal 옵션이 있는 경우 로컬/전역 상태를 강제로 재초기화합니다.
|
|
*
|
|
* SPA에서 같은 페이지로 다시 이동할 때 (예: 목록 → 편집 → 목록 → 편집)
|
|
* 이전에 수정한 폼 데이터가 남아있는 문제를 해결합니다.
|
|
*
|
|
* @example
|
|
* // data_sources에서 사용
|
|
* { "id": "board", "endpoint": "/api/boards/{{route.id}}", "initLocal": "formData", "refetchOnMount": true }
|
|
* // → 페이지 진입 시 항상 board 데이터를 다시 fetch하고 _local.formData를 초기화
|
|
*
|
|
* @default false
|
|
*/
|
|
refetchOnMount?: boolean;
|
|
|
|
/**
|
|
* 커스텀 HTTP 헤더
|
|
*
|
|
* API 요청 시 추가할 커스텀 헤더를 정의합니다.
|
|
* 값에 표현식({{...}})을 사용하여 동적으로 헤더 값을 설정할 수 있습니다.
|
|
*
|
|
* 지원되는 컨텍스트:
|
|
* - route: 라우트 파라미터 (예: {{route.id}})
|
|
* - query: 쿼리 파라미터 (예: {{query.mode}})
|
|
* - _global: 전역 상태 (예: {{_global.cartKey}})
|
|
*
|
|
* 주의:
|
|
* - _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다.
|
|
* - 값이 null, undefined, 빈 문자열인 헤더는 전송되지 않습니다.
|
|
*
|
|
* @example
|
|
* // 장바구니 키 헤더 전송
|
|
* {
|
|
* "id": "cart",
|
|
* "endpoint": "/api/cart",
|
|
* "headers": {
|
|
* "X-Cart-Key": "{{_global.cartKey}}"
|
|
* }
|
|
* }
|
|
*
|
|
* @example
|
|
* // 여러 커스텀 헤더
|
|
* {
|
|
* "id": "data",
|
|
* "endpoint": "/api/data",
|
|
* "headers": {
|
|
* "X-Custom-Header": "custom-value",
|
|
* "X-User-Id": "{{_global.currentUser?.uuid}}"
|
|
* }
|
|
* }
|
|
*/
|
|
headers?: Record<string, string>;
|
|
|
|
/**
|
|
* 조건부 로딩 표현식
|
|
*
|
|
* 표현식이 truthy로 평가될 때만 이 데이터 소스를 fetch합니다.
|
|
* 같은 id를 가진 여러 데이터 소스를 정의하고, 조건에 따라 하나만 실행되도록 할 수 있습니다.
|
|
*
|
|
* 지원되는 컨텍스트:
|
|
* - route: 라우트 파라미터 (예: {{route.id}})
|
|
* - query: 쿼리 파라미터 (예: {{query.mode}})
|
|
* - _global: 전역 상태 (예: {{_global.isAdmin}})
|
|
*
|
|
* 주의: _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다.
|
|
*
|
|
* @example
|
|
* // 수정 모드: route.id가 있을 때만 기존 데이터 로드
|
|
* { "id": "user", "endpoint": "/api/users/{{route.id}}", "if": "{{route.id}}" }
|
|
*
|
|
* // 생성 모드: route.id가 없을 때만 템플릿 로드
|
|
* { "id": "user", "endpoint": "/api/users/template", "if": "{{!route.id}}" }
|
|
*/
|
|
if?: string;
|
|
|
|
/**
|
|
* 조건부 데이터 소스 로드 조건 (복합 조건)
|
|
*
|
|
* if 속성의 상위 호환으로 AND/OR 그룹 및 if-else 체인을 지원합니다.
|
|
*
|
|
* 주의:
|
|
* - if와 conditions가 둘 다 있으면 if가 우선 평가됩니다
|
|
* - _local은 렌더링 전에 data_source가 처리되므로 사용할 수 없습니다
|
|
*
|
|
* @example
|
|
* // AND 그룹: route.id가 있고 권한이 있을 때만 로드
|
|
* {
|
|
* "id": "product",
|
|
* "endpoint": "/api/products/{{route.id}}",
|
|
* "conditions": {
|
|
* "and": ["{{!!route.id}}", "{{_global.hasPermission('view_product')}}"]
|
|
* }
|
|
* }
|
|
*
|
|
* @example
|
|
* // OR 그룹: admin이거나 manager일 때 로드
|
|
* {
|
|
* "id": "dashboard",
|
|
* "endpoint": "/api/admin/dashboard",
|
|
* "conditions": {
|
|
* "or": ["{{_global.user?.role === 'admin'}}", "{{_global.user?.role === 'manager'}}"]
|
|
* }
|
|
* }
|
|
*
|
|
* @since engine-v1.10.0
|
|
*/
|
|
conditions?: import('./helpers/ConditionEvaluator').ConditionsProperty;
|
|
|
|
/**
|
|
* WebSocket 채널명 (type이 'websocket'인 경우)
|
|
*
|
|
* @example "admin.dashboard"
|
|
*/
|
|
channel?: string;
|
|
|
|
/**
|
|
* WebSocket 이벤트명 (type이 'websocket'인 경우)
|
|
*
|
|
* @example "dashboard.stats.updated"
|
|
*/
|
|
event?: string;
|
|
|
|
/**
|
|
* WebSocket 채널 타입 (기본값: 'private')
|
|
*/
|
|
channel_type?: WebSocketChannelType;
|
|
|
|
/**
|
|
* WebSocket 수신 시 업데이트할 대상 데이터 소스 ID
|
|
*
|
|
* 이 필드가 설정되면 WebSocket 이벤트 수신 시
|
|
* 해당 ID의 데이터 소스 캐시를 업데이트합니다.
|
|
*
|
|
* @example "dashboard_stats"
|
|
*/
|
|
target_source?: string;
|
|
|
|
/**
|
|
* WebSocket 이벤트 수신 시 실행할 액션 목록 (engine-v1.33.0+)
|
|
*
|
|
* WebSocket 이벤트 수신 시 각 액션이 순차적으로 실행됩니다.
|
|
* 액션의 `$args[0]`로 수신 페이로드에 접근할 수 있습니다.
|
|
*
|
|
* 사용 예시: 수신 시 관련 데이터소스 refetch + 토스트 알림 표시
|
|
*
|
|
* @example
|
|
* [
|
|
* { "handler": "refetchDataSource", "params": { "dataSourceId": "notifications" } },
|
|
* { "handler": "toast", "params": { "type": "info", "message": "{{$args[0].subject}}" } }
|
|
* ]
|
|
*/
|
|
onReceive?: any[];
|
|
|
|
/**
|
|
* API 호출 실패 시 사용할 기본 데이터
|
|
*
|
|
* API 요청이 실패하거나 endpoint가 존재하지 않을 때
|
|
* 이 값이 데이터로 사용됩니다.
|
|
* errorHandling이 함께 정의된 경우 에러 핸들러가 먼저 실행된 후
|
|
* fallback 데이터가 렌더링에 사용됩니다.
|
|
*
|
|
* @example
|
|
* ```json
|
|
* // 객체 fallback
|
|
* { "fallback": { "users": 0, "posts": 0 } }
|
|
*
|
|
* // 배열 fallback
|
|
* { "fallback": [] }
|
|
*
|
|
* // 단순 값 fallback
|
|
* { "fallback": 0 }
|
|
* ```
|
|
*/
|
|
fallback?: any;
|
|
|
|
/**
|
|
* HTTP 에러 코드별 핸들러 매핑
|
|
*
|
|
* 이 데이터 소스의 API 호출 실패 시 에러 코드에 따라 다른 핸들러를 실행합니다.
|
|
* onError보다 우선순위가 높습니다.
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "errorHandling": {
|
|
* "403": { "handler": "showErrorPage", "params": { "target": "content" } },
|
|
* "404": { "handler": "toast", "params": { "type": "warning", "message": "$t:errors.not_found" } },
|
|
* "default": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
errorHandling?: ErrorHandlingMap;
|
|
|
|
/**
|
|
* 응답 조건부 에러 처리
|
|
*
|
|
* API가 200으로 응답하더라도 응답 데이터 기반으로 에러를 트리거합니다.
|
|
* 조건이 truthy로 평가되면 errorHandling에 정의된 핸들러가 실행됩니다.
|
|
*
|
|
* 표현식에서 `response` 객체로 API 응답 데이터에 접근합니다.
|
|
*
|
|
* @since engine-v1.18.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* {
|
|
* "errorCondition": {
|
|
* "if": "{{response?.data?.abilities?.can_update !== true}}",
|
|
* "errorCode": 403
|
|
* },
|
|
* "errorHandling": {
|
|
* "403": { "handler": "showErrorPage", "params": { "target": "content" } }
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
errorCondition?: ErrorCondition;
|
|
|
|
/**
|
|
* 범용 에러 핸들러 (폴백)
|
|
*
|
|
* errorHandling에 해당 에러 코드가 정의되지 않은 경우 실행됩니다.
|
|
* 단일 핸들러 또는 핸들러 배열을 지정할 수 있습니다.
|
|
*
|
|
* @example
|
|
* ```json
|
|
* // 단일 핸들러
|
|
* { "onError": { "handler": "toast", "params": { "type": "error", "message": "{{error.message}}" } } }
|
|
*
|
|
* // 핸들러 배열 (순차 실행)
|
|
* { "onError": [
|
|
* { "handler": "setState", "params": { "target": "global", "errorMessage": "{{error.message}}" } },
|
|
* { "handler": "openModal", "target": "error_modal" }
|
|
* ] }
|
|
* ```
|
|
*/
|
|
onError?: OnErrorHandler;
|
|
|
|
/**
|
|
* 데이터 로드 성공 시 실행할 핸들러
|
|
*
|
|
* API 요청이 성공하고 데이터가 캐시에 저장된 후 실행됩니다.
|
|
* params에서 {{response.xxx}} 형태로 API 응답에 접근할 수 있습니다.
|
|
*
|
|
* 주요 사용 사례:
|
|
* - 데이터 로드 후 조건부 모달 표시
|
|
* - 데이터 기반 추가 상태 설정
|
|
* - 로드 완료 후 UI 업데이트
|
|
*
|
|
* @since engine-v1.17.0
|
|
*
|
|
* @example
|
|
* ```json
|
|
* // 단일 핸들러 - 조건부 모달 표시
|
|
* {
|
|
* "onSuccess": {
|
|
* "handler": "conditions",
|
|
* "conditions": [
|
|
* {
|
|
* "if": "{{response.data.unavailable_items?.length > 0}}",
|
|
* "handler": "openModal",
|
|
* "target": "unavailable_modal"
|
|
* }
|
|
* ]
|
|
* }
|
|
* }
|
|
*
|
|
* // 핸들러 배열 (순차 실행)
|
|
* { "onSuccess": [
|
|
* { "handler": "setState", "params": { "target": "local", "loaded": true } },
|
|
* { "handler": "toast", "params": { "type": "success", "message": "Data loaded" } }
|
|
* ] }
|
|
*
|
|
* // sequence로 여러 작업 수행
|
|
* {
|
|
* "onSuccess": {
|
|
* "handler": "sequence",
|
|
* "actions": [
|
|
* { "handler": "setState", "params": { "target": "local", "resultData": "{{response.data}}" } },
|
|
* { "handler": "apiCall", "params": { "endpoint": "/api/log", "method": "POST" } }
|
|
* ]
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
onSuccess?: OnSuccessHandler;
|
|
|
|
/**
|
|
* 출처 메타 (편집 모드 전용 — engine-v1.50.0+)
|
|
*
|
|
* 레이아웃 편집기가 `with_source_meta=1` 로 요청할 때만 백엔드가 부여하는
|
|
* 소유 확장 식별 메타입니다. 동일 `id` 를 가진 data_source 가 여러 출처
|
|
* (템플릿/모듈/플러그인)에 정의되어 충돌할 때, 샘플 데이터 해소
|
|
* (`sampleDataProvider`)가 자기 출처의 샘플을 우선 선택하도록 합니다.
|
|
*
|
|
* 일반 렌더 경로에는 부여되지 않으며 저장 시 sanitize 에서 제외되므로
|
|
* 영속 레이아웃 JSON 에는 포함되지 않습니다.
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
__source?: {
|
|
kind: 'template' | 'module' | 'plugin' | 'core' | 'base' | 'route';
|
|
identifier?: string | null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 결과 인터페이스
|
|
*/
|
|
export interface DataSourceResult {
|
|
/**
|
|
* 데이터 소스 ID
|
|
*/
|
|
id: string;
|
|
|
|
/**
|
|
* 로딩 상태
|
|
*/
|
|
state: DataSourceLoadingState;
|
|
|
|
/**
|
|
* 데이터 (성공 시)
|
|
*/
|
|
data?: any;
|
|
|
|
/**
|
|
* 에러 (실패 시)
|
|
*/
|
|
error?: Error;
|
|
|
|
/**
|
|
* HTTP 에러 코드 (실패 시)
|
|
*/
|
|
errorCode?: number;
|
|
|
|
/**
|
|
* 에러가 핸들러에 의해 처리되었는지 여부
|
|
*/
|
|
errorHandled?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 매니저 옵션
|
|
*/
|
|
export interface DataSourceManagerOptions {
|
|
/**
|
|
* 401 응답 시 호출될 콜백
|
|
*/
|
|
onUnauthorized?: () => void;
|
|
|
|
/**
|
|
* 에러 발생 시 호출될 콜백
|
|
*/
|
|
onError?: (error: Error, source: DataSource) => void;
|
|
|
|
/**
|
|
* 샘플 데이터 프로바이더 (편집 모드 전용)
|
|
*
|
|
* 옵션이 주입되면 `fetchApiDataSource` 가 네트워크 fetch 대신 프로바이더의
|
|
* `resolve()` 결과를 반환한다. 일반 렌더 모드(편집기 외) 부팅에서는 옵션을
|
|
* 전달하지 않으므로 기존 동작 100% 보존. 계획서 참조.
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
sampleProvider?: SampleDataProvider;
|
|
|
|
/**
|
|
* onSuccess 핸들러 실행에 사용할 ActionDispatcher (편집 모드 전용)
|
|
*
|
|
* 옵션이 주입되면 `executeOnSuccessHandler` 가 전역 `getActionDispatcher()` 대신
|
|
* 본 dispatcher 를 사용한다. 일반 렌더 모드는 옵션 미전달 → 기존 동작 보존.
|
|
*
|
|
* 동기: PreviewCanvas 가 자체 DataSourceManager 인스턴스를 만들지만, 그 인스턴스
|
|
* 의 onSuccess 핸들러가 호스트 ActionDispatcher 를 호출하면 호스트 `setGlobalState`
|
|
* 가 발화되어 호스트 `#app` 이 통째로 unmount 되는 결함이 발생한다.
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
actionDispatcher?: import('./ActionDispatcher').ActionDispatcher;
|
|
}
|
|
|
|
/**
|
|
* 샘플 데이터 프로바이더 인터페이스 — 편집 모드 캔버스 전용
|
|
*
|
|
* `useSampleData` 가 병합 `editorSpec.sampleData`(byDataSourceId / bySource /
|
|
* byEndpointPattern) 로 구성한 뒤 편집 모드 부팅 시 `DataSourceManager` 옵션으로
|
|
* 주입한다. 미매칭 데이터소스는 도메인-중립 빈 응답으로 디그레이드한다
|
|
* (코어 키워드 프리셋 폐기 —-v1.50.0).
|
|
*
|
|
* @since engine-v1.50.0
|
|
*/
|
|
export interface SampleDataProvider {
|
|
/**
|
|
* 데이터소스 ID 가 샘플 모드의 매칭 후보인지 확인.
|
|
* `false` 면 일반 fetch 경로(네트워크) 로 폴백.
|
|
*/
|
|
has(dataSourceId: string): boolean;
|
|
|
|
/**
|
|
* 데이터소스에 대해 샘플 데이터를 동기/비동기로 반환.
|
|
* 반환값은 일반 API 응답과 같은 shape 이어야 한다(`{ data: [...] }` 등).
|
|
*/
|
|
resolve(dataSource: DataSource): unknown | Promise<unknown>;
|
|
}
|
|
|
|
/**
|
|
* 조건 평가용 컨텍스트 인터페이스
|
|
*/
|
|
export interface ConditionContext {
|
|
/**
|
|
* 라우트 파라미터
|
|
*/
|
|
route?: Record<string, string>;
|
|
|
|
/**
|
|
* 쿼리 파라미터
|
|
*/
|
|
query?: Record<string, string>;
|
|
|
|
/**
|
|
* 전역 상태
|
|
*/
|
|
_global?: Record<string, any>;
|
|
}
|
|
|
|
/**
|
|
* 데이터 캐시 엔트리 인터페이스
|
|
*/
|
|
interface DataCacheEntry {
|
|
/**
|
|
* 캐시된 데이터
|
|
*/
|
|
data: any;
|
|
|
|
/**
|
|
* 캐시 생성 시간
|
|
*/
|
|
timestamp: number;
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 매니저 클래스
|
|
*
|
|
* data_sources를 처리하고 API 요청을 관리
|
|
*/
|
|
export class DataSourceManager {
|
|
/**
|
|
* 데이터 캐시 만료 시간 (밀리초)
|
|
*
|
|
* 5분으로 설정하여 메모리 누수를 방지합니다.
|
|
*/
|
|
private static readonly DATA_CACHE_TTL = 300000; // 5분
|
|
|
|
private options: DataSourceManagerOptions;
|
|
private dataCache: Map<string, DataCacheEntry> = new Map();
|
|
private bindingEngine: DataBindingEngine;
|
|
|
|
/**
|
|
* 전역 헤더 규칙 (레이아웃에서 설정)
|
|
*
|
|
* API 호출 시 패턴에 매칭되는 엔드포인트에 자동으로 헤더를 추가합니다.
|
|
*
|
|
* @since engine-v1.16.0
|
|
*/
|
|
private globalHeaders: GlobalHeaderRule[] = [];
|
|
|
|
constructor(options: DataSourceManagerOptions = {}) {
|
|
this.options = options;
|
|
this.bindingEngine = new DataBindingEngine();
|
|
}
|
|
|
|
/**
|
|
* 전역 헤더 규칙을 설정합니다.
|
|
*
|
|
* 레이아웃 로드 시 호출되어 globalHeaders를 설정합니다.
|
|
* 이후 모든 API 호출에서 패턴 매칭을 통해 자동으로 헤더가 적용됩니다.
|
|
*
|
|
* @param headers 전역 헤더 규칙 배열
|
|
* @since engine-v1.16.0
|
|
*/
|
|
public setGlobalHeaders(headers: GlobalHeaderRule[]): void {
|
|
this.globalHeaders = headers || [];
|
|
}
|
|
|
|
/**
|
|
* 엔드포인트가 패턴에 매칭되는지 확인합니다.
|
|
*
|
|
* @param endpoint API 엔드포인트 경로
|
|
* @param pattern glob 스타일 패턴 ("*", "/api/shop/*" 등)
|
|
* @returns 매칭 여부
|
|
* @since engine-v1.16.0
|
|
*/
|
|
private matchesPattern(endpoint: string, pattern: string): boolean {
|
|
if (pattern === '*') return true;
|
|
|
|
// glob 패턴을 정규식으로 변환 (* → .*)
|
|
const regexPattern = pattern
|
|
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // 특수문자 이스케이프
|
|
.replace(/\*/g, '.*'); // * → .*
|
|
|
|
return new RegExp(`^${regexPattern}$`).test(endpoint);
|
|
}
|
|
|
|
/**
|
|
* 엔드포인트에 매칭되는 전역 헤더를 추출합니다.
|
|
*
|
|
* globalHeaders 배열을 순회하며 패턴이 매칭되는 모든 헤더를 병합합니다.
|
|
* 나중에 정의된 규칙의 헤더가 먼저 정의된 규칙의 헤더를 덮어씁니다.
|
|
*
|
|
* @param endpoint API 엔드포인트 경로
|
|
* @param context 표현식 평가를 위한 컨텍스트
|
|
* @returns 병합된 헤더 객체
|
|
* @since engine-v1.16.0
|
|
*/
|
|
/**
|
|
* params 객체를 FormData로 변환합니다.
|
|
* File/Blob은 그대로 append, 기타 값은 문자열 변환하여 append합니다.
|
|
*/
|
|
private toFormData(params: Record<string, any>): FormData {
|
|
const formData = new FormData();
|
|
for (const [key, value] of Object.entries(params)) {
|
|
if (value instanceof File || value instanceof Blob) {
|
|
formData.append(key, value);
|
|
} else if (value !== null && value !== undefined) {
|
|
formData.append(
|
|
key,
|
|
typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
);
|
|
}
|
|
}
|
|
return formData;
|
|
}
|
|
|
|
private getMatchingGlobalHeaders(
|
|
endpoint: string,
|
|
context: Record<string, any>,
|
|
): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
|
|
for (const rule of this.globalHeaders) {
|
|
if (this.matchesPattern(endpoint, rule.pattern)) {
|
|
Object.entries(rule.headers).forEach(([key, value]) => {
|
|
// 표현식 평가 ({{_global.xxx}} 등)
|
|
const resolved = resolveParamValue(value, context);
|
|
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
|
|
if (resolved != null && resolved !== '') {
|
|
result[key] = String(resolved);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 조건부 데이터 소스 필터링
|
|
*
|
|
* 같은 id를 가진 데이터 소스 중 조건을 만족하는 첫 번째 것만 선택합니다.
|
|
* - if 속성이 없는 데이터 소스는 항상 선택됩니다.
|
|
* - if 속성이 있는 데이터 소스는 조건이 truthy일 때만 선택됩니다.
|
|
* - 같은 id가 여러 개인 경우, 조건을 만족하는 첫 번째 것만 선택됩니다.
|
|
*
|
|
* @param sources 데이터 소스 배열
|
|
* @param conditionContext 조건 평가용 컨텍스트 (route, query, _global)
|
|
* @returns 필터링된 데이터 소스 배열
|
|
*/
|
|
filterByCondition(
|
|
sources: DataSource[],
|
|
conditionContext: ConditionContext = {}
|
|
): DataSource[] {
|
|
// 이미 선택된 id를 추적
|
|
const selectedIds = new Set<string>();
|
|
const filteredSources: DataSource[] = [];
|
|
|
|
for (const source of sources) {
|
|
// 이미 같은 id가 선택되었으면 스킵
|
|
if (selectedIds.has(source.id)) {
|
|
continue;
|
|
}
|
|
|
|
// if와 conditions가 모두 없으면 항상 선택
|
|
if (!source.if && !source.conditions) {
|
|
selectedIds.add(source.id);
|
|
filteredSources.push(source);
|
|
continue;
|
|
}
|
|
|
|
// 조건 평가 (evaluateRenderCondition은 if와 conditions 모두 지원)
|
|
const shouldInclude = evaluateRenderCondition(
|
|
{ if: source.if, conditions: source.conditions },
|
|
conditionContext,
|
|
this.bindingEngine,
|
|
`data_source:${source.id}`
|
|
);
|
|
|
|
if (shouldInclude) {
|
|
selectedIds.add(source.id);
|
|
filteredSources.push(source);
|
|
}
|
|
}
|
|
|
|
return filteredSources;
|
|
}
|
|
|
|
/**
|
|
* errorCondition을 평가하여 응답 데이터 기반 에러 조건을 체크합니다.
|
|
*
|
|
* @param source 데이터 소스
|
|
* @param responseData API 응답 데이터
|
|
* @returns errorCondition이 truthy면 해당 errorCode, 아니면 null
|
|
*/
|
|
private checkErrorCondition(source: DataSource, responseData: any): number | null {
|
|
if (!source.errorCondition?.if || source.errorCondition?.errorCode === undefined) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const context = { response: responseData };
|
|
const result = evaluateRenderCondition(
|
|
{ if: source.errorCondition.if },
|
|
context,
|
|
this.bindingEngine,
|
|
`errorCondition:${source.id}`
|
|
);
|
|
|
|
if (result) {
|
|
logger.log(`errorCondition matched for ${source.id}, triggering error ${source.errorCondition.errorCode}`);
|
|
return source.errorCondition.errorCode;
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to evaluate errorCondition for ${source.id}:`, error);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 fetch (blocking + progressive 전용)
|
|
*
|
|
* @param sources 데이터 소스 배열
|
|
* @param routeParams 라우트 파라미터
|
|
* @param queryParams 쿼리 파라미터
|
|
* @param globalState _global 상태 (globalHeaders 표현식 평가용)
|
|
* @param localState _local 상태 (globalHeaders 표현식 평가용)
|
|
* @returns 데이터 맵 (key: 데이터 소스 ID, value: 데이터)
|
|
*/
|
|
async fetchDataSources(
|
|
sources: DataSource[],
|
|
routeParams: Record<string, string> = {},
|
|
queryParams: URLSearchParams = new URLSearchParams(),
|
|
globalState?: Record<string, any>,
|
|
localState?: Record<string, any>,
|
|
): Promise<Record<string, any>> {
|
|
const results: Record<string, any> = {};
|
|
|
|
// auto_fetch가 true인 데이터 소스만 처리 (websocket 타입 제외)
|
|
//
|
|
// 샘플 모드(편집기) 예외: auto_fetch:false 인 지연 소스(탭/모달 활성 시 런타임에
|
|
// fetch)는 정적 시뮬레이션인 편집기 캔버스에서 활성화 액션을 실행할 수 없어, 페이지
|
|
// 상태로 게이트를 열어도 데이터가 비어 본체가 미렌더된다(myComments
|
|
// 내댓글 서브탭). 샘플 모드에서는 지연 소스도 샘플 주입 대상에 포함해 게이트 노출 시
|
|
// 본체가 렌더되게 한다(런타임 동작과 정합 — 런타임은 탭 활성 시 동일 데이터를 fetch).
|
|
// 일반 렌더 모드(sampleProvider 미주입)는 기존 auto_fetch 필터를 100% 보존한다.
|
|
const inSampleMode = !!this.options.sampleProvider;
|
|
const autoFetchSources = sources.filter(
|
|
(source) =>
|
|
(inSampleMode || source.auto_fetch !== false) && source.type !== 'websocket'
|
|
);
|
|
|
|
// 병렬 처리
|
|
await Promise.all(
|
|
autoFetchSources.map(async (source) => {
|
|
try {
|
|
const data = await this.fetchDataSource(source, routeParams, queryParams, globalState, localState);
|
|
results[source.id] = data;
|
|
this.dataCache.set(source.id, { data, timestamp: Date.now() });
|
|
|
|
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
|
|
const errorConditionCode = this.checkErrorCondition(source, data);
|
|
if (errorConditionCode !== null) {
|
|
const errorContext: ErrorContext = {
|
|
status: errorConditionCode,
|
|
message: `Error condition matched for ${source.id}`,
|
|
data: data,
|
|
};
|
|
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorConditionCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
if (resolveResult.handler) {
|
|
try {
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
results[source.id] = source.fallback;
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
|
|
}
|
|
|
|
return; // onSuccess 스킵
|
|
}
|
|
|
|
// onSuccess 핸들러 실행
|
|
if (source.onSuccess) {
|
|
try {
|
|
const successContext: SuccessContext = {
|
|
data,
|
|
sourceId: source.id,
|
|
};
|
|
await this.executeOnSuccessHandler(source.onSuccess, successContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch data source: ${source.id}`, error);
|
|
|
|
// HTTP 에러 코드 추출
|
|
const errorCode = (error as any)?.response?.status ||
|
|
(error as any)?.status ||
|
|
500;
|
|
|
|
// 에러 컨텍스트 생성
|
|
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
|
|
const responseData = (error as any)?.response?.data;
|
|
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
|
|
const errorContext: ErrorContext = {
|
|
status: errorCode,
|
|
message: errorMessage,
|
|
errors: responseData?.errors,
|
|
data: responseData,
|
|
statusText: (error as any)?.response?.statusText,
|
|
};
|
|
|
|
// ErrorHandlingResolver를 통해 핸들러 결정 및 실행
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
if (resolveResult.handler) {
|
|
try {
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
results[source.id] = source.fallback;
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id}`);
|
|
}
|
|
|
|
// 기존 onError 콜백도 호출 (호환성 유지)
|
|
if (this.options.onError) {
|
|
this.options.onError(error as Error, source);
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 데이터 소스 fetch (with result tracking)
|
|
*
|
|
* @param sources 데이터 소스 배열
|
|
* @param routeParams 라우트 파라미터
|
|
* @param queryParams 쿼리 파라미터
|
|
* @param globalState _global 상태 (선택적, refetchDataSource 시 endpoint 표현식에서 _global 접근 지원)
|
|
* @param localState _local 상태 (선택적, refetchDataSource 시 params 표현식에서 _local 접근 지원)
|
|
* @returns 데이터 소스 결과 배열
|
|
* @param options fetch 옵션
|
|
* - ignoreAutoFetch: true면 auto_fetch: false인 소스도 강제 fetch (refetchDataSource 용)
|
|
*/
|
|
async fetchDataSourcesWithResults(
|
|
sources: DataSource[],
|
|
routeParams: Record<string, string> = {},
|
|
queryParams: URLSearchParams = new URLSearchParams(),
|
|
globalState?: Record<string, any>,
|
|
localState?: Record<string, any>,
|
|
options?: { ignoreAutoFetch?: boolean },
|
|
): Promise<DataSourceResult[]> {
|
|
// 계약: WebSocket 소스는 fetch 대상이 아님 — 호출자가 사전 필터링해야 함 (engine-v1.32.5)
|
|
// 내부 filter는 safety net으로 유지하되, 호출자가 필터를 누락하면 dev 모드에서 경고
|
|
// (이전에는 silent filter로 인해 호출자의 인덱스 기반 매핑이 조용히 어긋나는 버그 발생)
|
|
const hasWebSocket = sources.some((source) => source.type === 'websocket');
|
|
if (hasWebSocket) {
|
|
const wsIds = sources
|
|
.filter((source) => source.type === 'websocket')
|
|
.map((s) => s.id)
|
|
.join(', ');
|
|
logger.warn(
|
|
`fetchDataSourcesWithResults received WebSocket sources (caller should filter them out before calling): ${wsIds}. ` +
|
|
`WebSocket sources are event listeners, not data providers — they should not be passed to fetch.`
|
|
);
|
|
}
|
|
|
|
// ignoreAutoFetch 옵션이 true면 auto_fetch 필터 무시 (refetchDataSource에서 사용)
|
|
// 그렇지 않으면 auto_fetch가 true인 데이터 소스만 처리 (websocket 타입 제외 — safety net)
|
|
const targetSources = options?.ignoreAutoFetch
|
|
? sources.filter((source) => source.type !== 'websocket')
|
|
: sources.filter(
|
|
(source) => source.auto_fetch !== false && source.type !== 'websocket'
|
|
);
|
|
|
|
// 병렬 처리 (각 결과를 추적)
|
|
const results = await Promise.all(
|
|
targetSources.map(async (source): Promise<DataSourceResult> => {
|
|
try {
|
|
const data = await this.fetchDataSource(source, routeParams, queryParams, globalState, localState);
|
|
this.dataCache.set(source.id, { data, timestamp: Date.now() });
|
|
|
|
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
|
|
const errorConditionCode = this.checkErrorCondition(source, data);
|
|
if (errorConditionCode !== null) {
|
|
const errorContext: ErrorContext = {
|
|
status: errorConditionCode,
|
|
message: `Error condition matched for ${source.id}`,
|
|
data: data,
|
|
};
|
|
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorConditionCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
let errorHandled = false;
|
|
if (resolveResult.handler) {
|
|
// blocking 데이터소스의 경우:
|
|
// 핸들러를 await하면 데드락 발생 (showErrorPage가 #main_content를 대기하지만,
|
|
// blocking이 끝나야 #main_content가 마운트됨)
|
|
// → 핸들러를 비동기로 실행하고 즉시 blocking 해제
|
|
if (source.loading_strategy === 'blocking') {
|
|
resolver.execute(resolveResult.handler, errorContext).catch((handlerError) => {
|
|
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
|
|
});
|
|
errorHandled = true;
|
|
} else {
|
|
try {
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
errorHandled = true;
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
|
|
return {
|
|
id: source.id,
|
|
state: 'success',
|
|
data: source.fallback,
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: source.id,
|
|
state: 'error',
|
|
error: new Error(`Error condition matched: ${errorConditionCode}`),
|
|
errorCode: errorConditionCode,
|
|
errorHandled,
|
|
};
|
|
}
|
|
|
|
// onSuccess 핸들러 실행
|
|
if (source.onSuccess) {
|
|
try {
|
|
const successContext: SuccessContext = {
|
|
data,
|
|
sourceId: source.id,
|
|
};
|
|
await this.executeOnSuccessHandler(source.onSuccess, successContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: source.id,
|
|
state: 'success',
|
|
data,
|
|
};
|
|
} catch (error) {
|
|
logger.error(`Failed to fetch data source: ${source.id}`, error);
|
|
|
|
// HTTP 에러 코드 추출
|
|
const errorCode = (error as any)?.response?.status ||
|
|
(error as any)?.status ||
|
|
500;
|
|
|
|
// 에러 컨텍스트 생성
|
|
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
|
|
const responseData = (error as any)?.response?.data;
|
|
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
|
|
const errorContext: ErrorContext = {
|
|
status: errorCode,
|
|
message: errorMessage,
|
|
errors: responseData?.errors,
|
|
data: responseData,
|
|
statusText: (error as any)?.response?.statusText,
|
|
};
|
|
|
|
// ErrorHandlingResolver를 통해 핸들러 결정
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
let errorHandled = false;
|
|
|
|
if (resolveResult.handler) {
|
|
// blocking 데이터소스의 경우:
|
|
// 핸들러를 await하면 데드락 발생 (showErrorPage가 #main_content를 대기하지만,
|
|
// blocking이 끝나야 #main_content가 마운트됨)
|
|
// → 핸들러를 비동기로 실행하고 즉시 blocking 해제
|
|
if (source.loading_strategy === 'blocking') {
|
|
resolver.execute(resolveResult.handler, errorContext).catch((handlerError) => {
|
|
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
|
|
});
|
|
errorHandled = true;
|
|
} else {
|
|
try {
|
|
// 핸들러 실행
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
errorHandled = true;
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id}`);
|
|
return {
|
|
id: source.id,
|
|
state: 'success',
|
|
data: source.fallback,
|
|
};
|
|
}
|
|
|
|
// 기존 onError 콜백도 호출 (호환성 유지)
|
|
if (this.options.onError) {
|
|
this.options.onError(error as Error, source);
|
|
}
|
|
|
|
return {
|
|
id: source.id,
|
|
state: 'error',
|
|
error: error as Error,
|
|
errorCode,
|
|
errorHandled,
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* 백그라운드에서 데이터 소스 fetch (비동기, 결과 반환 안 함)
|
|
*
|
|
* @param sources 데이터 소스 배열
|
|
* @param routeParams 라우트 파라미터
|
|
* @param queryParams 쿼리 파라미터
|
|
* @param onUpdate 데이터 업데이트 콜백
|
|
*/
|
|
fetchDataSourcesInBackground(
|
|
sources: DataSource[],
|
|
routeParams: Record<string, string> = {},
|
|
queryParams: URLSearchParams = new URLSearchParams(),
|
|
onUpdate?: (sourceId: string, data: any) => void,
|
|
): void {
|
|
// auto_fetch가 true이고 loading_strategy가 background인 소스만 처리 (websocket 타입 제외)
|
|
const backgroundSources = sources.filter(
|
|
(source) => source.auto_fetch !== false && source.loading_strategy === 'background' && source.type !== 'websocket'
|
|
);
|
|
|
|
// 각 소스를 백그라운드에서 fetch (await 없이)
|
|
backgroundSources.forEach((source) => {
|
|
this.fetchDataSource(source, routeParams, queryParams)
|
|
.then(async (data) => {
|
|
this.dataCache.set(source.id, { data, timestamp: Date.now() });
|
|
|
|
// errorCondition 체크: 200 응답이어도 조건 만족 시 에러로 처리
|
|
const errorConditionCode = this.checkErrorCondition(source, data);
|
|
if (errorConditionCode !== null) {
|
|
const errorContext: ErrorContext = {
|
|
status: errorConditionCode,
|
|
message: `Error condition matched for ${source.id}`,
|
|
data: data,
|
|
};
|
|
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorConditionCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
if (resolveResult.handler) {
|
|
try {
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing errorCondition handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id} (errorCondition)`);
|
|
if (onUpdate) {
|
|
onUpdate(source.id, source.fallback);
|
|
}
|
|
}
|
|
|
|
return; // onSuccess 스킵
|
|
}
|
|
|
|
// onSuccess 핸들러 실행
|
|
if (source.onSuccess) {
|
|
try {
|
|
const successContext: SuccessContext = {
|
|
data,
|
|
sourceId: source.id,
|
|
};
|
|
await this.executeOnSuccessHandler(source.onSuccess, successContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing onSuccess handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
if (onUpdate) {
|
|
onUpdate(source.id, data);
|
|
}
|
|
})
|
|
.catch(async (error) => {
|
|
logger.error(`Background fetch failed: ${source.id}`, error);
|
|
|
|
// HTTP 에러 코드 추출
|
|
const errorCode = (error as any)?.response?.status ||
|
|
(error as any)?.status ||
|
|
500;
|
|
|
|
// 에러 컨텍스트 생성
|
|
// API 응답의 message를 우선 사용하고, 없으면 Axios 에러 메시지 사용
|
|
const responseData = (error as any)?.response?.data;
|
|
const errorMessage = responseData?.message || (error as Error).message || 'Unknown error';
|
|
const errorContext: ErrorContext = {
|
|
status: errorCode,
|
|
message: errorMessage,
|
|
errors: responseData?.errors,
|
|
data: responseData,
|
|
statusText: (error as any)?.response?.statusText,
|
|
};
|
|
|
|
// ErrorHandlingResolver를 통해 핸들러 결정 및 실행
|
|
const resolver = getErrorHandlingResolver();
|
|
const resolveResult = resolver.resolve(errorCode, {
|
|
errorHandling: source.errorHandling,
|
|
onError: source.onError,
|
|
});
|
|
|
|
if (resolveResult.handler) {
|
|
try {
|
|
await resolver.execute(resolveResult.handler, errorContext);
|
|
} catch (handlerError) {
|
|
logger.error(`Error executing error handler for ${source.id}:`, handlerError);
|
|
}
|
|
}
|
|
|
|
// fallback이 정의된 경우 렌더링용 데이터로 사용
|
|
if (source.fallback !== undefined) {
|
|
this.dataCache.set(source.id, { data: source.fallback, timestamp: Date.now() });
|
|
logger.log(`Using fallback data for ${source.id}`);
|
|
if (onUpdate) {
|
|
onUpdate(source.id, source.fallback);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 기존 onError 콜백도 호출 (호환성 유지)
|
|
if (this.options.onError) {
|
|
this.options.onError(error as Error, source);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 단일 데이터 소스 fetch
|
|
*
|
|
* @param source 데이터 소스
|
|
* @param routeParams 라우트 파라미터
|
|
* @param queryParams 쿼리 파라미터
|
|
* @param globalState _global 상태 (선택적)
|
|
* @returns 데이터
|
|
*/
|
|
private async fetchDataSource(
|
|
source: DataSource,
|
|
routeParams: Record<string, string>,
|
|
queryParams: URLSearchParams,
|
|
globalState?: Record<string, any>,
|
|
localState?: Record<string, any>,
|
|
): Promise<any> {
|
|
switch (source.type) {
|
|
case 'api':
|
|
return this.fetchApiDataSource(source, routeParams, queryParams, globalState, localState);
|
|
case 'static':
|
|
return source.data;
|
|
case 'route_params':
|
|
return routeParams;
|
|
case 'query_params':
|
|
return parseQueryParams(queryParams);
|
|
default:
|
|
throw new Error(`Unknown data source type: ${source.type}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* API 데이터 소스 fetch
|
|
*
|
|
* @param source 데이터 소스
|
|
* @param routeParams 라우트 파라미터
|
|
* @param queryParams 쿼리 파라미터
|
|
* @param globalState _global 상태 (선택적)
|
|
* @param localState _local 상태 (선택적, params 표현식에서 _local 접근 지원)
|
|
* @returns 데이터
|
|
*/
|
|
private async fetchApiDataSource(
|
|
source: DataSource,
|
|
routeParams: Record<string, string>,
|
|
queryParams: URLSearchParams,
|
|
globalState?: Record<string, any>,
|
|
localState?: Record<string, any>,
|
|
): Promise<any> {
|
|
// 샘플 모드 분기 — 편집기 부팅 시에만 옵션이 주입됨.
|
|
// 일반 렌더 모드는 옵션 미주입이므로 이 분기에 진입하지 않아 기존 fetch 경로 100% 보존.
|
|
if (this.options.sampleProvider?.has(source.id)) {
|
|
const sample = await Promise.resolve(this.options.sampleProvider.resolve(source));
|
|
return sample;
|
|
}
|
|
|
|
if (!source.endpoint) {
|
|
throw new Error(`API data source ${source.id} has no endpoint`);
|
|
}
|
|
|
|
// 표현식 평가용 컨텍스트 구성
|
|
// _global이 전달된 경우 endpoint 표현식에서 _global.xxx 접근 가능
|
|
// _local이 전달된 경우 params 표현식에서 _local.xxx 접근 가능
|
|
// parseQueryParams를 사용하여 배열 쿼리 파라미터 지원 (예: sales_status[]=a&sales_status[]=b)
|
|
const expressionContext: Record<string, any> = {
|
|
route: routeParams,
|
|
query: parseQueryParams(queryParams),
|
|
};
|
|
|
|
// globalState가 전달된 경우 _global로 추가
|
|
if (globalState) {
|
|
expressionContext._global = globalState;
|
|
}
|
|
|
|
// localState가 전달된 경우 _local로 추가
|
|
// sequence 내 setState local 후 refetchDataSource에서 {{_local.xxx}} 접근 지원
|
|
if (localState) {
|
|
expressionContext._local = localState;
|
|
}
|
|
|
|
// endpoint의 {{...}} 표현식 치환 (RenderHelpers의 범용 함수 사용)
|
|
// 복잡한 표현식({{route?.id}}, 삼항 연산자 등)도 지원
|
|
// IMPORTANT: skipCache: true - 매 요청마다 새로운 컨텍스트로 평가해야 함 (페이지네이션 등)
|
|
let endpoint = resolveExpressionString(source.endpoint, expressionContext, { skipCache: true });
|
|
|
|
const method = source.method || 'GET';
|
|
|
|
// contentType 해석 (multipart/form-data 지원)
|
|
const resolvedContentType = source.contentType
|
|
? (typeof source.contentType === 'string' && source.contentType.includes('{{'))
|
|
? resolveExpressionString(source.contentType, expressionContext, { skipCache: true })
|
|
: source.contentType
|
|
: 'application/json';
|
|
const isMultipart = resolvedContentType === 'multipart/form-data';
|
|
|
|
// IMPORTANT: source.params를 deep clone하여 원본 보존 (레이아웃 캐시 변형 방지)
|
|
// multipart/form-data인 경우 File/Blob 객체가 JSON 직렬화로 소실되므로 shallow clone 사용
|
|
let params = isMultipart
|
|
? { ...(source.params || {}) }
|
|
: JSON.parse(JSON.stringify(source.params || {}));
|
|
|
|
// 파라미터 치환 (resolveParamValue 사용)
|
|
// {{route.id}}, {{route?.id}}, {{query.page}}, 복잡한 표현식 모두 지원
|
|
// IMPORTANT: 배열 등 원본 타입 유지를 위해 resolveParamValue 사용
|
|
// (resolveExpressionString은 항상 문자열 반환하여 배열이 JSON 문자열로 변환됨)
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (typeof value === 'string') {
|
|
params[key] = resolveParamValue(value, expressionContext);
|
|
}
|
|
});
|
|
|
|
// 빈 문자열인 파라미터 제거 (API로 전송하지 않음)
|
|
Object.keys(params).forEach((key) => {
|
|
if (params[key] === '') {
|
|
delete params[key];
|
|
}
|
|
});
|
|
|
|
// DevTools 요청 추적
|
|
const devTools = getDevTools();
|
|
let requestId: string | null = null;
|
|
if (devTools?.isEnabled()) {
|
|
// 데이터소스 정의 정보 등록
|
|
devTools.trackDataSourceDefinition({
|
|
id: source.id,
|
|
type: source.type,
|
|
endpoint: endpoint,
|
|
method: method,
|
|
autoFetch: source.auto_fetch,
|
|
initLocal: source.initLocal,
|
|
initGlobal: source.initGlobal,
|
|
});
|
|
|
|
// DevTools용 전체 URL 생성 (쿼리 파라미터 포함)
|
|
let trackingUrl = endpoint;
|
|
if (method === 'GET' && Object.keys(params).length > 0) {
|
|
const searchParams = new URLSearchParams();
|
|
Object.entries(params).forEach(([key, value]) => {
|
|
if (Array.isArray(value)) {
|
|
value.forEach(v => searchParams.append(key, String(v)));
|
|
} else if (value != null) {
|
|
searchParams.append(key, String(value));
|
|
}
|
|
});
|
|
const queryString = searchParams.toString();
|
|
trackingUrl = queryString ? `${endpoint}?${queryString}` : endpoint;
|
|
}
|
|
|
|
requestId = devTools.trackRequest(trackingUrl, method, {
|
|
requestBody: method !== 'GET' ? params : undefined,
|
|
dataSourceId: source.id,
|
|
});
|
|
devTools.trackDataSourceLoading(source.id);
|
|
}
|
|
|
|
try {
|
|
let result: any;
|
|
|
|
// auth_mode 결정 (auth_required: true는 'required'로 처리하여 하위 호환성 유지)
|
|
const authMode = source.auth_mode ?? (source.auth_required === true ? 'required' : 'none');
|
|
|
|
// 토큰 유무 확인
|
|
const hasToken = AuthManager.getInstance().isAuthenticated();
|
|
|
|
// auth_mode: 'required'이고 토큰이 없으면 요청 스킵 (fallback 사용)
|
|
// 토큰 없이 ApiClient로 요청하면 401 → onUnauthorized → 로그인 리다이렉트 발생
|
|
if (authMode === 'required' && !hasToken) {
|
|
logger.log(`[DataSourceManager] Skipping ${source.id}: auth_mode is 'required' but no token available`);
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackDataSourceError(source.id, 'Skipped: auth_mode required but not authenticated');
|
|
if (requestId) {
|
|
devTools.failRequest(requestId, 'Skipped: not authenticated');
|
|
}
|
|
}
|
|
const skipError = new Error('Auth required but not authenticated') as any;
|
|
skipError.response = { status: 401, statusText: 'Unauthorized', data: null };
|
|
skipError.status = 401;
|
|
skipError._authSkipped = true;
|
|
throw skipError;
|
|
}
|
|
|
|
// ApiClient 사용 여부 결정
|
|
// - required: ApiClient 사용 (토큰 존재 확인됨)
|
|
// - optional: 토큰이 있을 때만 ApiClient 사용
|
|
// - none: 일반 fetch 사용
|
|
const useApiClient = authMode === 'required' || (authMode === 'optional' && hasToken);
|
|
|
|
if (useApiClient) {
|
|
const apiClient = getApiClient();
|
|
|
|
// ApiClient는 baseURL로 '/api'를 사용하므로, endpoint에서 '/api' 제거
|
|
const normalizedEndpoint = endpoint.startsWith('/api/')
|
|
? endpoint.substring(4) // '/api/' 제거 -> '/'로 시작
|
|
: endpoint.startsWith('/api')
|
|
? endpoint.substring(4) || '/' // '/api' 제거 -> '/' 또는 원래 경로
|
|
: endpoint;
|
|
|
|
if (this.options.onError) {
|
|
logger.log(`[DataSourceManager] Normalized endpoint: ${endpoint} -> ${normalizedEndpoint}`);
|
|
}
|
|
|
|
// 전역 헤더 + 커스텀 헤더 병합 (source.headers가 globalHeaders보다 우선)
|
|
// 1. globalHeaders에서 패턴 매칭된 헤더 추출
|
|
const globalHeadersResolved = this.getMatchingGlobalHeaders(endpoint, expressionContext);
|
|
|
|
// 2. source.headers 처리 (개별 데이터소스 헤더)
|
|
const sourceHeaders: Record<string, string> = {};
|
|
if (source.headers) {
|
|
Object.entries(source.headers).forEach(([headerName, headerValue]) => {
|
|
// 표현식 평가 ({{...}} 치환)
|
|
const resolvedValue = resolveParamValue(headerValue, expressionContext);
|
|
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
|
|
if (resolvedValue != null && resolvedValue !== '') {
|
|
sourceHeaders[headerName] = String(resolvedValue);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 3. 병합 (source.headers가 globalHeaders를 덮어씀)
|
|
const customHeaders: Record<string, string> = {
|
|
...globalHeadersResolved,
|
|
...sourceHeaders,
|
|
};
|
|
|
|
// 커스텀 헤더가 있는 경우 config에 포함
|
|
const hasCustomHeaders = Object.keys(customHeaders).length > 0;
|
|
|
|
// multipart/form-data인 경우 params를 FormData로 변환
|
|
// Axios는 FormData를 받으면 Content-Type을 자동으로 multipart/form-data(+boundary)로 설정
|
|
const requestData = isMultipart ? this.toFormData(params) : params;
|
|
|
|
// HTTP 메서드에 따라 요청
|
|
switch (method) {
|
|
case 'GET':
|
|
result = await apiClient.get(normalizedEndpoint, {
|
|
params,
|
|
...(hasCustomHeaders && { headers: customHeaders }),
|
|
});
|
|
break;
|
|
case 'POST':
|
|
if (hasCustomHeaders) {
|
|
result = await apiClient.post(normalizedEndpoint, requestData, { headers: customHeaders });
|
|
} else {
|
|
result = await apiClient.post(normalizedEndpoint, requestData);
|
|
}
|
|
break;
|
|
case 'PUT':
|
|
if (hasCustomHeaders) {
|
|
result = await apiClient.put(normalizedEndpoint, requestData, { headers: customHeaders });
|
|
} else {
|
|
result = await apiClient.put(normalizedEndpoint, requestData);
|
|
}
|
|
break;
|
|
case 'PATCH':
|
|
if (hasCustomHeaders) {
|
|
result = await apiClient.patch(normalizedEndpoint, requestData, { headers: customHeaders });
|
|
} else {
|
|
result = await apiClient.patch(normalizedEndpoint, requestData);
|
|
}
|
|
break;
|
|
case 'DELETE':
|
|
result = await apiClient.delete(normalizedEndpoint, {
|
|
params,
|
|
...(hasCustomHeaders && { headers: customHeaders }),
|
|
});
|
|
break;
|
|
default:
|
|
throw new Error(`Unsupported HTTP method: ${method}`);
|
|
}
|
|
} else {
|
|
// auth_mode가 'none'이거나 'optional'인데 토큰이 없는 경우 일반 fetch 사용
|
|
let url = endpoint;
|
|
|
|
// GET 요청인 경우 쿼리 스트링 생성
|
|
if (method === 'GET') {
|
|
const queryString = new URLSearchParams(params).toString();
|
|
url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
|
}
|
|
|
|
// 기본 헤더 (multipart/form-data는 Content-Type 생략 - 브라우저 자동 설정)
|
|
const requestHeaders: Record<string, string> = {
|
|
...(isMultipart ? {} : { 'Content-Type': 'application/json' }),
|
|
'Accept': 'application/json',
|
|
};
|
|
|
|
// g7_locale이 설정되어 있으면 Accept-Language 헤더로 전송
|
|
if (typeof window !== 'undefined') {
|
|
const locale = localStorage.getItem('g7_locale');
|
|
if (locale) {
|
|
requestHeaders['Accept-Language'] = locale;
|
|
}
|
|
}
|
|
|
|
// 전역 헤더 적용 (globalHeaders)
|
|
const globalHeadersResolved = this.getMatchingGlobalHeaders(endpoint, expressionContext);
|
|
Object.assign(requestHeaders, globalHeadersResolved);
|
|
|
|
// 커스텀 헤더 처리 (source.headers가 globalHeaders보다 우선)
|
|
if (source.headers) {
|
|
Object.entries(source.headers).forEach(([headerName, headerValue]) => {
|
|
// 표현식 평가 ({{...}} 치환)
|
|
const resolvedValue = resolveParamValue(headerValue, expressionContext);
|
|
// null, undefined, 빈 문자열이 아닌 경우에만 헤더 추가
|
|
if (resolvedValue != null && resolvedValue !== '') {
|
|
requestHeaders[headerName] = String(resolvedValue);
|
|
}
|
|
});
|
|
}
|
|
|
|
// multipart/form-data인 경우 FormData로 변환
|
|
let fetchBody: BodyInit | undefined;
|
|
if (method !== 'GET') {
|
|
if (isMultipart) {
|
|
fetchBody = this.toFormData(params);
|
|
// multipart 시 Content-Type 헤더 제거 (FormData 사용 시 브라우저가 boundary 포함하여 설정)
|
|
delete requestHeaders['Content-Type'];
|
|
} else {
|
|
fetchBody = JSON.stringify(params);
|
|
}
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: requestHeaders,
|
|
body: fetchBody,
|
|
});
|
|
|
|
// 응답 본문 파싱 (성공/실패 모두)
|
|
let responseBody: any;
|
|
try {
|
|
responseBody = await response.json();
|
|
} catch {
|
|
responseBody = null;
|
|
}
|
|
|
|
// DevTools 요청 완료 추적 (성공/실패 모두 상태 코드와 응답 기록)
|
|
if (requestId && devTools?.isEnabled()) {
|
|
devTools.completeRequest(requestId, response.status, responseBody);
|
|
// 이미 기록했으므로 catch에서 중복 기록 방지
|
|
requestId = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
// 에러 응답도 DevTools에 기록 후 throw
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackDataSourceError(source.id, `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
// response 정보를 Error에 포함하여 상위 catch에서 정확한 상태 코드 추출 가능
|
|
const httpError = new Error(`HTTP error! status: ${response.status}`) as any;
|
|
httpError.response = {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
data: responseBody,
|
|
};
|
|
httpError.status = response.status;
|
|
throw httpError;
|
|
}
|
|
|
|
result = responseBody;
|
|
|
|
// DevTools 데이터소스 로드 완료 추적
|
|
if (devTools?.isEnabled()) {
|
|
devTools.trackDataSourceLoaded(source.id, result);
|
|
}
|
|
}
|
|
|
|
// DevTools 요청 완료 추적 (ApiClient 사용 시)
|
|
if (useApiClient && requestId && devTools?.isEnabled()) {
|
|
// ApiClient는 성공 응답만 반환하므로 200으로 기록
|
|
// (에러 시 catch 블록으로 이동)
|
|
devTools.completeRequest(requestId, 200, result);
|
|
devTools.trackDataSourceLoaded(source.id, result);
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
// DevTools 요청 실패 추적 (ApiClient 에러 또는 네트워크 에러)
|
|
if (requestId && devTools?.isEnabled()) {
|
|
// axios 에러인 경우 상태 코드와 응답 데이터 추출
|
|
const axiosError = error as any;
|
|
if (axiosError?.response) {
|
|
// HTTP 에러 응답이 있는 경우 상태 코드와 응답 본문 기록
|
|
devTools.completeRequest(
|
|
requestId,
|
|
axiosError.response.status,
|
|
axiosError.response.data
|
|
);
|
|
} else {
|
|
// 네트워크 에러 등 응답이 없는 경우
|
|
devTools.failRequest(requestId, error instanceof Error ? error.message : String(error));
|
|
}
|
|
devTools.trackDataSourceError(source.id, error instanceof Error ? error.message : String(error));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 캐시된 데이터 조회
|
|
*
|
|
* TTL이 만료된 캐시는 자동으로 삭제됩니다.
|
|
*
|
|
* @param sourceId 데이터 소스 ID
|
|
* @returns 캐시된 데이터 (없거나 만료되면 undefined)
|
|
*/
|
|
getCachedData(sourceId: string): any | undefined {
|
|
const entry = this.dataCache.get(sourceId);
|
|
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
|
|
// TTL 체크
|
|
const now = Date.now();
|
|
if (now - entry.timestamp > DataSourceManager.DATA_CACHE_TTL) {
|
|
this.dataCache.delete(sourceId);
|
|
return undefined;
|
|
}
|
|
|
|
return entry.data;
|
|
}
|
|
|
|
/**
|
|
* 캐시 초기화
|
|
*/
|
|
clearCache(): void {
|
|
this.dataCache.clear();
|
|
}
|
|
|
|
/**
|
|
* WebSocket 데이터 소스를 구독합니다.
|
|
*
|
|
* type이 'websocket'인 데이터 소스를 필터링하여
|
|
* WebSocketManager를 통해 채널을 구독하고 이벤트를 리스닝합니다.
|
|
*
|
|
* @param sources 데이터 소스 배열
|
|
* @param onUpdate 데이터 업데이트 시 호출될 콜백 (sourceId, data)
|
|
* @returns 구독 키 배열 (구독 해제 시 사용)
|
|
*/
|
|
subscribeWebSockets(
|
|
sources: DataSource[],
|
|
onUpdate: (sourceId: string, data: unknown) => void,
|
|
bindingContext: Record<string, any> = {}
|
|
): string[] {
|
|
const subscriptionKeys: string[] = [];
|
|
const webSocketSources = sources.filter((s) => s.type === 'websocket');
|
|
|
|
if (webSocketSources.length === 0) {
|
|
return subscriptionKeys;
|
|
}
|
|
|
|
webSocketSources.forEach((source) => {
|
|
if (!source.channel || !source.event) {
|
|
logger.warn(`WebSocket source ${source.id} missing channel or event`);
|
|
return;
|
|
}
|
|
|
|
// 채널/이벤트명에 포함된 표현식 평가 (예: core.user.notifications.{{current_user.data.uuid}})
|
|
// 평가 실패 시 원본 문자열 유지 — 정적 채널은 영향 없음
|
|
let resolvedChannel: string;
|
|
let resolvedEvent: string;
|
|
try {
|
|
resolvedChannel = resolveExpressionString(source.channel, bindingContext, { skipCache: true });
|
|
resolvedEvent = resolveExpressionString(source.event, bindingContext, { skipCache: true });
|
|
} catch (error) {
|
|
logger.error(`Failed to resolve WebSocket channel/event for source ${source.id}:`, error);
|
|
return;
|
|
}
|
|
|
|
// 평가 결과 검증: 표현식 미평가, 빈 세그먼트(trailing/leading/연속 dot), 빈 문자열은 모두 잘못된 채널
|
|
// 표현식이 undefined로 평가되어 빈 문자열로 치환된 경우 trailing dot 등이 발생함
|
|
const isInvalidChannel = (channel: string): boolean => {
|
|
if (!channel || channel.trim() === '') return true;
|
|
if (channel.includes('{{')) return true; // 미평가 표현식
|
|
if (channel.endsWith('.') || channel.endsWith(':')) return true; // trailing 빈 세그먼트
|
|
if (channel.startsWith('.') || channel.startsWith(':')) return true; // leading 빈 세그먼트
|
|
if (channel.includes('..') || channel.includes('::')) return true; // 중간 빈 세그먼트
|
|
return false;
|
|
};
|
|
|
|
if (isInvalidChannel(resolvedChannel) || isInvalidChannel(resolvedEvent)) {
|
|
logger.warn(
|
|
`WebSocket source ${source.id} has invalid channel/event after expression resolution, skipping. ` +
|
|
`Original channel="${source.channel}", resolved="${resolvedChannel}". ` +
|
|
`Original event="${source.event}", resolved="${resolvedEvent}". ` +
|
|
`Available context keys: [${Object.keys(bindingContext).join(', ')}]`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const key = webSocketManager.subscribe(
|
|
resolvedChannel,
|
|
resolvedEvent,
|
|
(data: unknown) => {
|
|
// target_source가 지정된 경우 해당 소스의 캐시 업데이트
|
|
const targetId = source.target_source || source.id;
|
|
this.dataCache.set(targetId, { data, timestamp: Date.now() });
|
|
onUpdate(targetId, data);
|
|
},
|
|
{ channelType: source.channel_type || 'private' }
|
|
);
|
|
|
|
if (key) {
|
|
subscriptionKeys.push(key);
|
|
}
|
|
});
|
|
|
|
return subscriptionKeys;
|
|
}
|
|
|
|
/**
|
|
* WebSocket 구독을 해제합니다.
|
|
*
|
|
* @param subscriptionKeys 구독 키 배열
|
|
*/
|
|
unsubscribeWebSockets(subscriptionKeys: string[]): void {
|
|
if (subscriptionKeys.length === 0) {
|
|
return;
|
|
}
|
|
|
|
subscriptionKeys.forEach((key) => {
|
|
webSocketManager.unsubscribe(key);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* onSuccess 핸들러를 실행합니다.
|
|
*
|
|
* 데이터 소스 fetch 성공 후 정의된 onSuccess 핸들러를 실행합니다.
|
|
* params에서 {{response.xxx}} 형태로 응답 데이터에 접근할 수 있습니다.
|
|
*
|
|
* @param onSuccess onSuccess 핸들러 설정
|
|
* @param successContext 성공 컨텍스트 (data, sourceId)
|
|
*/
|
|
private async executeOnSuccessHandler(
|
|
onSuccess: OnSuccessHandler,
|
|
successContext: SuccessContext
|
|
): Promise<void> {
|
|
// 옵션 주입된 dispatcher 우선 (편집기 격리 모드), 없으면 전역 호스트 dispatcher
|
|
// (편집기 캔버스의 onSuccess setState 가 호스트 globalState
|
|
// 를 변경해 호스트 #app 이 unmount 되는 결함 방지)
|
|
const actionDispatcher = this.options.actionDispatcher ?? getActionDispatcher();
|
|
|
|
if (!actionDispatcher) {
|
|
logger.warn('ActionDispatcher not available, skipping onSuccess handler');
|
|
return;
|
|
}
|
|
|
|
// 핸들러 배열로 정규화
|
|
const handlers = Array.isArray(onSuccess) ? onSuccess : [onSuccess];
|
|
|
|
// 순차 실행
|
|
for (const handler of handlers) {
|
|
try {
|
|
// response 컨텍스트로 핸들러 실행
|
|
// {{response.data}}, {{response.sourceId}} 등으로 접근 가능
|
|
await actionDispatcher.dispatchAction(
|
|
{
|
|
type: 'click', // 이벤트 타입은 의미 없음 (dispatchAction에서 사용하지 않음)
|
|
...handler,
|
|
},
|
|
{
|
|
data: {
|
|
response: successContext,
|
|
},
|
|
}
|
|
);
|
|
} catch (error) {
|
|
logger.error(`Failed to execute onSuccess handler:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전역 데이터 소스 매니저 인스턴스
|
|
*/
|
|
export const dataSourceManager = new DataSourceManager();
|