Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Foundry와 Gotham 사이의 드래그앤드롭 보강 구현

보강에는 같은 플랫폼 인스턴스의 Foundry와 Gotham 및 type mapping이 필요합니다. 플랫폼 인스턴스, 온톨로지와 대상 객체 유형에서 매핑을 준비합니다. 같은 플랫폼의 표현끼리 주고받는 경우에는 이 보강을 추가할 필요가 없습니다.

기본 드래그앤드롭을 준비한 뒤 보강 위치를 정합니다. 양쪽에서 같은 작업을 반복할 필요는 없습니다.

  • 송신 앱이 Foundry와 Gotham 모두로 드래그를 허용하려면 drag 전에 두 표현을 준비합니다.
  • 수신 앱이 Gotham ID만 사용하면서 두 표현을 모두 받으려면 drop 후 필요한 경우에 보강합니다.

Data Bank 요청 계약

Data Bank Service는 다음 두 미디어 유형 사이의 동의 표현을 조회합니다. 객체 집합 미디어 유형은 이 보강 요청의 지원 목록에 포함되지 않습니다.

표현미디어 유형
Foundry 객체 RIDapplication/x-vnd.palantir.rid.phonograph2-objects.object
Gotham 객체 IDapplication/x-vnd.palantir.rid.gotham.object

요청은 PUT /data-bank-service/api/data-transfer/batchEnrichDataTransfer입니다. dataTransfers 배열의 각 원소는 미디어 유형에서 직렬화한 문자열로 대응되는 JSON 객체입니다. 브라우저의 DataTransfer 객체 자체를 요청으로 보내지 않습니다.

아래 세 ID는 요청 구조를 보여 주는 가상 값입니다. 실제 호출 파일에는 접근할 수 있고 type mapping을 구성한 객체 식별자를 넣습니다.

{
  "dataTransfers": [
    {
      "application/x-vnd.palantir.rid.gotham.object": "[\"ri.gotham.XXXXXXXX\", \"ri.gotham.YYYYYYYY\", \"ri.gotham.ZZZZZZZZZZ\"]"
    }
  ]
}

FOUNDRY_HOST는 https://로 시작하는 플랫폼 호스트, FOUNDRY_TOKEN은 Bearer 접두어를 제외한 토큰, DATA_TRANSFER_REQUEST_FILE은 실제 요청 JSON 파일의 경로입니다. 토큰은 저장소에 기록하지 않고 외부에서 공급합니다.

: "${FOUNDRY_HOST:?HTTPS 플랫폼 호스트를 지정하세요}"
: "${FOUNDRY_TOKEN:?외부에서 접근 토큰을 공급하세요}"
: "${DATA_TRANSFER_REQUEST_FILE:?실제 식별자를 담은 JSON 파일을 지정하세요}"
curl --fail-with-body --show-error --silent \
  --request PUT \
  "${FOUNDRY_HOST%/}/data-bank-service/api/data-transfer/batchEnrichDataTransfer" \
  --header "Content-Type: application/json" \
  --header "Authorization: Bearer ${FOUNDRY_TOKEN}" \
  --data-binary "@${DATA_TRANSFER_REQUEST_FILE}"

응답의 dataTransfers 원소에는 dataTransfer와 errors가 있습니다. dataTransfer에는 원래 표현과 조회한 동의 표현이 문자열로 들어갑니다. errors는 해당 보강에서 발생한 오류 목록입니다. HTTP 성공과 보강 오류 없음은 별도로 확인합니다. 지원하지 않는 미디어 유형도 오류 원인입니다.

동의 표현을 얻지 못하면 플랫폼 인스턴스와 온톨로지에서 type mapping을 활성화했는지, 대상 객체 유형의 Gotham 통합을 구성했는지 확인합니다. 요청한 표현이 지원되는 유형이고 예상한 대상에 매핑되는지도 확인합니다. 두 표현의 배열 길이나 순서가 일대일로 대응한다고 가정하지 않습니다.

이 주소는 공개 /api/v2 경로가 아닌 내부 서비스 경로입니다. 앱에서 직접 호출할 때는 앱 접근 제한과 실제 인증 구성을 확인합니다. 고급 API 보안의 공개 API용 api:use-* scope를 부여했다는 사실만으로 내부 서비스 호출이 허용되지는 않습니다.

HTTP 함수와 오류 처리

다음 세 코드 블록은 같은 enrichment.ts 파일을 구성합니다. 전체 모듈도 사용할 수 있습니다.

createEnricher는 신뢰하는 플랫폼 URL과 앱의 getAccessToken 함수를 받습니다. getAccessToken은 현재 사용할 수 있는 원시 토큰 문자열을 비동기로 반환해야 합니다. 입력은 미디어 유형별 JSON 문자열이고 출력은 보강 데이터와 오류 목록입니다. 네트워크, HTTP 또는 응답 형식 오류는 예외로 전달합니다.

URL은 앱 설정에서 공급하며 drop payload에서 읽지 않습니다. 이 예제의 UI는 errors가 하나라도 있거나 필요한 동의 표현이 없으면 처리를 중단합니다. 일부 결과만 적용하려는 앱은 오류 항목과 적용할 결과를 별도로 사용자에게 설명해야 합니다.

export const FOUNDRY_OBJECTS = "application/x-vnd.palantir.rid.phonograph2-objects.object";
export const GOTHAM_OBJECTS = "application/x-vnd.palantir.rid.gotham.object";
export type SerializedTransfer = Record<string, string>;
export interface EnrichmentResult {
  dataTransfer: SerializedTransfer;
  errors: unknown[];
}
export type Enricher = (input: SerializedTransfer) => Promise<EnrichmentResult>;

function record(value: unknown, label: string): Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
    throw new TypeError(`${label}: JSON 객체가 필요합니다.`);
  }
  return value as Record<string, unknown>;
}

export function parseIdentifiers(text: string): string[] {
  const value: unknown = JSON.parse(text);
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
    throw new TypeError("객체 식별자의 문자열 배열이 필요합니다.");
  }
  return value;
}

export function createEnricher(
  foundryBaseUrl: string,
  getAccessToken: () => Promise<string>,
): Enricher {
  const url = new URL(
    "/data-bank-service/api/data-transfer/batchEnrichDataTransfer",
    foundryBaseUrl,
  );
  return async (input) => {
    for (const [mediaType, serialized] of Object.entries(input)) {
      if (mediaType !== FOUNDRY_OBJECTS && mediaType !== GOTHAM_OBJECTS) {
        throw new Error(`지원하지 않는 보강 미디어 유형: ${mediaType}`);
      }
      parseIdentifiers(serialized);
    }
    const token = await getAccessToken();
    if (token.length === 0) throw new Error("접근 토큰을 받지 못했습니다.");
    const response = await fetch(url, {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ dataTransfers: [input] }),
    });
    if (!response.ok) throw new Error(`Data Bank 요청 실패: HTTP ${response.status}`);
    const body: unknown = await response.json();
    const root = record(body, "응답");
    if (!Array.isArray(root.dataTransfers) || root.dataTransfers.length !== 1) {
      throw new TypeError("단일 입력에 대한 dataTransfers 결과가 필요합니다.");
    }
    const first = record(root.dataTransfers[0], "dataTransfers[0]");
    const values = record(first.dataTransfer, "dataTransfer");
    if (!Array.isArray(first.errors)) throw new TypeError("errors 배열이 필요합니다.");
    const dataTransfer: SerializedTransfer = Object.create(null);
    for (const [mediaType, serialized] of Object.entries(values)) {
      if (typeof serialized !== "string") {
        throw new TypeError(`문자열로 직렬화되지 않은 결과: ${mediaType}`);
      }
      dataTransfer[mediaType] = serialized;
    }
    return { dataTransfer, errors: first.errors };
  };
}

function synonym(result: EnrichmentResult, mediaType: string): string[] {
  if (result.errors.length !== 0) {
    throw new Error(`보강 오류: ${JSON.stringify(result.errors)}`);
  }
  const serialized = result.dataTransfer[mediaType];
  if (serialized === undefined) throw new Error(`동의 표현을 찾지 못했습니다: ${mediaType}`);
  return parseIdentifiers(serialized);
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : String(error);
}

Drag 전에 준비

installEnrichedDragZone은 실제 DOM 요소, 상태를 표시할 요소와 Enricher를 받습니다. 반환한 update에 현재 Gotham ID 목록을 전달합니다. 페이지나 컴포넌트를 준비할 때, 그리고 선택이 바뀔 때 호출합니다. 데이터가 없거나 보강 중이면 드래그를 비활성화하며, 두 표현을 준비한 경우에만 true를 반환합니다.

export function installEnrichedDragZone(
  element: HTMLElement,
  status: HTMLElement,
  enrich: Enricher,
) {
  let payload: SerializedTransfer | null = null;
  let version = 0;
  let disposed = false;
  element.draggable = false;

  const onDragStart = (event: DragEvent) => {
    if (payload === null || event.dataTransfer === null) {
      event.preventDefault();
      return;
    }
    for (const [mediaType, serialized] of Object.entries(payload)) {
      event.dataTransfer.setData(mediaType, serialized);
    }
    event.dataTransfer.effectAllowed = "move";
  };
  element.addEventListener("dragstart", onDragStart);

  return {
    async update(gothamObjectIds: readonly string[]): Promise<boolean> {
      if (disposed) throw new Error("해제한 drag zone은 갱신할 수 없습니다.");
      const current = ++version;
      payload = null;
      element.draggable = false;
      if (gothamObjectIds.length === 0) {
        status.textContent = "드래그할 객체를 선택하세요.";
        return false;
      }
      status.textContent = "Foundry 동의 표현을 찾는 중입니다.";
      try {
        const original = JSON.stringify(gothamObjectIds);
        const result = await enrich({ [GOTHAM_OBJECTS]: original });
        const foundryIds = synonym(result, FOUNDRY_OBJECTS);
        if (foundryIds.length === 0) throw new Error("연결된 Foundry 객체가 없습니다.");
        if (current !== version) return false;
        payload = {
          [GOTHAM_OBJECTS]: original,
          [FOUNDRY_OBJECTS]: JSON.stringify(foundryIds),
        };
        element.draggable = true;
        status.textContent = "Gotham과 Foundry 표현을 드래그할 수 있습니다.";
        return true;
      } catch (error) {
        if (current === version) status.textContent = errorMessage(error);
        return false;
      }
    },
    dispose() {
      disposed = true;
      version += 1;
      payload = null;
      element.draggable = false;
      element.removeEventListener("dragstart", onDragStart);
    },
  };
}

setData는 직렬화한 문자열을 받습니다. JSON.parse로 얻은 배열을 그대로 넣지 않습니다. dragstart에서는 미리 준비한 문자열을 동기적으로 쓰며 네트워크 응답을 기다리지 않습니다. effectAllowed="move"는 브라우저 드래그 동작이며 객체 삭제를 수행하지 않습니다.

새 update가 시작하면 이전 payload를 비우고 늦게 돌아온 이전 요청을 사용하지 않습니다. 앱에서 요소를 제거할 때 dispose를 호출하면 listener를 해제하고 남은 응답의 화면 반영도 막습니다. 이 제어가 이미 보낸 HTTP 요청을 취소하지는 않습니다.

Drop 후 필요한 표현 조회

다음 연결 함수는 Gotham 데이터가 이미 있으면 직접 사용합니다. Foundry 데이터만 있으면 해당 문자열을 동기적으로 확보한 뒤 보강을 요청합니다. 빈 식별자 목록은 빈 수신 목록으로 표시하고 추가 요청하지 않습니다.

세 DOM 인자는 drop 대상, 상태 안내 요소, 식별자 목록 요소입니다. 목록 요소는 ul 또는 ol을 사용하고 상태 요소에는 role="status"를 설정합니다. 함수가 반환한 정리 함수를 요소 제거 시 호출합니다.

export function installGothamDropZone(
  element: HTMLElement,
  status: HTMLElement,
  list: HTMLElement,
  enrich: Enricher,
): () => void {
  let version = 0;
  const allowDrop = (event: DragEvent) => {
    const types = event.dataTransfer?.types ?? [];
    if (types.includes(GOTHAM_OBJECTS) || types.includes(FOUNDRY_OBJECTS)) {
      event.preventDefault();
    }
  };
  const onDrop = async (event: DragEvent) => {
    event.preventDefault();
    const current = ++version;
    list.replaceChildren();
    element.setAttribute("aria-busy", "true");
    status.textContent = "드롭한 데이터를 처리하는 중입니다.";
    try {
      if (event.dataTransfer === null) throw new Error("DataTransfer가 없습니다.");
      const gothamData = event.dataTransfer.getData(GOTHAM_OBJECTS);
      const foundryData = event.dataTransfer.getData(FOUNDRY_OBJECTS);
      let identifiers: string[];
      if (gothamData !== "") {
        identifiers = parseIdentifiers(gothamData);
      } else if (foundryData !== "") {
        const foundryIds = parseIdentifiers(foundryData);
        if (foundryIds.length === 0) {
          identifiers = [];
        } else {
          const result = await enrich({ [FOUNDRY_OBJECTS]: foundryData });
          identifiers = synonym(result, GOTHAM_OBJECTS);
          if (identifiers.length === 0) throw new Error("연결된 Gotham 객체가 없습니다.");
        }
      } else {
        throw new Error("Foundry 또는 Gotham 객체 식별자를 받지 못했습니다.");
      }
      if (current !== version) return;
      const fragment = document.createDocumentFragment();
      for (const identifier of identifiers) {
        const item = document.createElement("li");
        item.textContent = identifier;
        fragment.appendChild(item);
      }
      list.replaceChildren(fragment);
      status.textContent = `${identifiers.length}개 Gotham 식별자를 받았습니다.`;
    } catch (error) {
      if (current === version) status.textContent = errorMessage(error);
    } finally {
      if (current === version) element.setAttribute("aria-busy", "false");
    }
  };
  element.addEventListener("dragenter", allowDrop);
  element.addEventListener("dragover", allowDrop);
  element.addEventListener("drop", onDrop);
  return () => {
    version += 1;
    element.removeEventListener("dragenter", allowDrop);
    element.removeEventListener("dragover", allowDrop);
    element.removeEventListener("drop", onDrop);
    element.setAttribute("aria-busy", "false");
  };
}

getData는 첫 await 전에 실행합니다. 비동기 처리가 시작된 후 DataTransfer를 다시 읽으면 브라우저의 보호 모드 때문에 필요한 데이터를 얻지 못할 수 있습니다. 이 예제는 받은 ID를 textContent로 표시하며 실제 업무 기능은 그 ID를 사용하는 해당 앱의 조회와 상태 처리에 연결합니다.

수신 처리 중인 상태와 오류는 화면에 남깁니다. drop 영역의 유효/무효, loading 및 hover 모양은 디자인 지침을 적용합니다. 보강마다 추가 서비스 요청이 발생하므로 이미 필요한 표현이 있는 payload는 다시 보강하지 않습니다.