From 5173cfb480fcc42b984f695acd6e544f0b0f09fb Mon Sep 17 00:00:00 2001 From: airslice Date: Thu, 20 Nov 2025 13:53:55 +0800 Subject: [PATCH] feat: support public api --- public/reearth.yml | 39 +++ .../inspector_block/main/PropertyValue.tsx | 4 + src/extensions/visualizer/main/hooks.ts | 248 ++++-------------- .../main/utils/getItemsFromIntegrationAPI.ts | 202 ++++++++++++++ .../main/utils/getItemsFromPublicAPI.ts | 187 +++++++++++++ .../main/utils/getItemsFromServer.ts | 25 ++ 6 files changed, 510 insertions(+), 195 deletions(-) create mode 100644 src/extensions/visualizer/main/utils/getItemsFromIntegrationAPI.ts create mode 100644 src/extensions/visualizer/main/utils/getItemsFromPublicAPI.ts create mode 100644 src/extensions/visualizer/main/utils/getItemsFromServer.ts diff --git a/public/reearth.yml b/public/reearth.yml index 020ca86..d1c8847 100644 --- a/public/reearth.yml +++ b/public/reearth.yml @@ -18,6 +18,8 @@ extensions: label: CMS Data Visualizer Server - key: cms_integration_api label: CMS Integration API + - key: cms_public_api + label: CMS Public API - id: server_base_url title: Server Base URL type: string @@ -76,6 +78,43 @@ extensions: field: data_source_type type: string value: cms_integration_api + - id: public_api_base_url + title: Public API Base URL + type: string + availableIf: + field: data_source_type + type: string + value: cms_public_api + - id: cms_workspace_id_for_public_api + title: CMS Workspace ID + type: string + availableIf: + field: data_source_type + type: string + value: cms_public_api + - id: cms_project_id_for_public_api + title: CMS Project ID + type: string + availableIf: + field: data_source_type + type: string + value: cms_public_api + - id: cms_model_id_for_public_api + title: CMS Model ID + type: string + availableIf: + field: data_source_type + type: string + value: cms_public_api + - id: value_filters_for_public_api + title: Filters + description: "Optional, specify value filters, Example: status===published|reviewed;category===news" + type: string + ui: multiline + availableIf: + field: data_source_type + type: string + value: cms_public_api - id: visualization title: Visualization fields: diff --git a/src/extensions/inspector_block/main/PropertyValue.tsx b/src/extensions/inspector_block/main/PropertyValue.tsx index 5ab6d43..fefb54b 100644 --- a/src/extensions/inspector_block/main/PropertyValue.tsx +++ b/src/extensions/inspector_block/main/PropertyValue.tsx @@ -63,6 +63,10 @@ const PropertyValue: FC = ({ property }) => {
{new Date(property.value as string).toLocaleString()}
+ ) : property.type === "object" ? ( +
+ {JSON.stringify(property.value)} +
) : (
{property.value?.toString()} diff --git a/src/extensions/visualizer/main/hooks.ts b/src/extensions/visualizer/main/hooks.ts index fae39a6..09f64e3 100644 --- a/src/extensions/visualizer/main/hooks.ts +++ b/src/extensions/visualizer/main/hooks.ts @@ -1,49 +1,48 @@ import { useEffect } from "react"; +import { getItemsFromIntegrationAPI } from "./utils/getItemsFromIntegrationAPI"; +import { getItemsFromPublicAPI } from "./utils/getItemsFromPublicAPI"; +import { getItemsFromServer } from "./utils/getItemsFromServer"; + import { postMsg } from "@/shared/utils"; type WidgetProperty = { api: { data_source_type?: "cms_data_visualizer_server" | "cms_integration_api"; + // for get items from server server_base_url?: string; server_api_key?: string; + // for get items from integration API integration_api_base_url?: string; integration_api_key?: string; cms_workspace_id?: string; cms_project_id?: string; cms_model_id?: string; value_filters?: string; + // for get items from public API + public_api_base_url?: string; + cms_workspace_id_for_public_api?: string; + cms_project_id_for_public_api?: string; + cms_model_id_for_public_api?: string; + value_filters_for_public_api?: string; }; appearance: { marker_appearance?: string; }; }; -type Schema = { - id: string; - key: string; - multiple: boolean; - name: string; - required: boolean; - type: string; -}[]; - -// Sub-collect Asset type -type Asset = { - id: string; - url: string; -}; - -type Item = { +export type Item = { id: string; fields: Field[]; }; -type Field = { +export type Field = { id: string; key: string; type: string; value: unknown; + group?: string; + name?: string; }; export default () => { @@ -74,24 +73,15 @@ export default () => { ); return; } + try { - const url = `${widgetProperty.api.server_base_url}/items`; - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${widgetProperty.api.server_api_key}`, - }, + const items = await getItemsFromServer({ + baseUrl: widgetProperty.api.server_base_url, + apiKey: widgetProperty.api.server_api_key, }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - postMsg("addLayer", data.data.items || []); + postMsg("addLayer", items); } catch (error) { - console.error("Error fetching data:", error); + console.error("Error fetching data from server:", error); } } else if ( // Fetch data from CMS Integration API @@ -109,177 +99,45 @@ export default () => { ); return; } - - // Fetch Assets with pagination - let assets: Asset[]; try { - const baseUrl = `${widgetProperty.api.integration_api_base_url}/${widgetProperty.api.cms_workspace_id}/projects/${widgetProperty.api.cms_project_id}/assets`; - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${widgetProperty.api.integration_api_key}`, - }; - // First request to get total count - const firstResponse = await fetch(`${baseUrl}?perPage=100&page=1`, { - method: "GET", - headers, + const items = await getItemsFromIntegrationAPI({ + baseUrl: widgetProperty.api.integration_api_base_url, + apiKey: widgetProperty.api.integration_api_key, + workspaceId: widgetProperty.api.cms_workspace_id, + projectId: widgetProperty.api.cms_project_id, + modelId: widgetProperty.api.cms_model_id, + valueFilters: widgetProperty.api.value_filters, }); - - if (!firstResponse.ok) { - throw new Error(`HTTP error! status: ${firstResponse.status}`); - } - - const firstData = await firstResponse.json(); - const totalCount = firstData.totalCount; - const perPage = 100; - const totalPages = Math.ceil(totalCount / perPage); - - assets = firstData.items || []; - - // Fetch remaining pages if there are more - if (totalPages > 1) { - const pagePromises = []; - for (let page = 2; page <= totalPages; page++) { - pagePromises.push( - fetch(`${baseUrl}?perPage=${perPage}&page=${page}`, { - method: "GET", - headers, - }) - ); - } - - const responses = await Promise.all(pagePromises); - - for (const response of responses) { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - assets = assets.concat(data.items || []); - } - } + postMsg("addLayer", items); } catch (error) { - console.error("Error fetching assets:", error); + console.error("Error fetching data from CMS integration API:", error); } - - // Fetch Schema - let schema: Schema; - try { - const url = `${widgetProperty.api.integration_api_base_url}/${widgetProperty.api.cms_workspace_id}/projects/${widgetProperty.api.cms_project_id}/models/${widgetProperty.api.cms_model_id}`; - const response = await fetch(url, { - method: "GET", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${widgetProperty.api.integration_api_key}`, - }, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - schema = data.schema.fields; - } catch (error) { - console.error("Error fetching schema:", error); + } else if ( + // Fetch data from CMS Public API + widgetProperty.api.data_source_type === "cms_public_api" + ) { + if ( + !widgetProperty.api.public_api_base_url || + !widgetProperty.api.cms_workspace_id_for_public_api || + !widgetProperty.api.cms_project_id_for_public_api || + !widgetProperty.api.cms_model_id_for_public_api + ) { + console.warn( + "Please set the Public API Base URL, CMS Workspace ID, CMS Project ID, and CMS Model ID in the widget properties." + ); + return; } - - // Fetch Items with pagination try { - const baseUrl = `${widgetProperty.api.integration_api_base_url}/${widgetProperty.api.cms_workspace_id}/projects/${widgetProperty.api.cms_project_id}/models/${widgetProperty.api.cms_model_id}/items`; - - const headers = { - "Content-Type": "application/json", - Authorization: `Bearer ${widgetProperty.api.integration_api_key}`, - }; - - // First request to get total count - const firstResponse = await fetch(`${baseUrl}?perPage=100&page=1`, { - method: "GET", - headers, + const items = await getItemsFromPublicAPI({ + baseUrl: widgetProperty.api.public_api_base_url, + workspaceId: widgetProperty.api.cms_workspace_id_for_public_api, + projectId: widgetProperty.api.cms_project_id_for_public_api, + modelId: widgetProperty.api.cms_model_id_for_public_api, + valueFilters: widgetProperty.api.value_filters_for_public_api, }); - - if (!firstResponse.ok) { - throw new Error(`HTTP error! status: ${firstResponse.status}`); - } - - const firstData = await firstResponse.json(); - const totalCount = firstData.totalCount; - const perPage = 100; - const totalPages = Math.ceil(totalCount / perPage); - - let allItems: Item[] = firstData.items || []; - - // Fetch remaining pages if there are more - if (totalPages > 1) { - const pagePromises = []; - for (let page = 2; page <= totalPages; page++) { - pagePromises.push( - fetch(`${baseUrl}?perPage=${perPage}&page=${page}`, { - method: "GET", - headers, - }) - ); - } - - const responses = await Promise.all(pagePromises); - - for (const response of responses) { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - allItems = allItems.concat(data.items || []); - } - } - - // append schema's name to each item - // replace asset id with asset url in each item - allItems = allItems.map((item: Item) => ({ - ...item, - fields: [ - ...item.fields.map((field: Field) => ({ - ...field, - name: schema.find((s) => s.key === field.key)?.name, - value: - field.type === "asset" && typeof field.value === "string" - ? assets.find((a) => a.id === field.value)?.url || - field.value - : field.type === "asset" && Array.isArray(field.value) - ? (field.value as string[]) - .map( - (assetId) => - assets.find((a) => a.id === assetId)?.url || - assetId - ) - .reverse() - : field.value, - })), - ], - })); - - // Apply value filters if any - // Example: status===published|reviewed;category===news - if (widgetProperty.api.value_filters) { - const filters = widgetProperty.api.value_filters - .split(";") - .map((filter) => { - const [key, values] = filter.split("==="); - return { key, values: values.split("|") }; - }); - - allItems = allItems.filter((item) => - filters.every((filter) => { - const field = item.fields.find((f) => f.key === filter.key); - if (!field) return false; - return filter.values.includes(String(field.value)); - }) - ); - } - - postMsg("addLayer", allItems); + postMsg("addLayer", items); } catch (error) { - console.error("Error fetching data:", error); + console.error("Error fetching data from CMS public API:", error); } } } diff --git a/src/extensions/visualizer/main/utils/getItemsFromIntegrationAPI.ts b/src/extensions/visualizer/main/utils/getItemsFromIntegrationAPI.ts new file mode 100644 index 0000000..2a28149 --- /dev/null +++ b/src/extensions/visualizer/main/utils/getItemsFromIntegrationAPI.ts @@ -0,0 +1,202 @@ +import { Field, Item } from "../hooks"; + +type Schema = { + id: string; + key: string; + multiple: boolean; + name: string; + required: boolean; + type: string; +}[]; + +// Sub-collect Asset type +type Asset = { + id: string; + url: string; +}; + +export const getItemsFromIntegrationAPI = async ({ + baseUrl, + apiKey, + workspaceId, + projectId, + modelId, + valueFilters, +}: { + baseUrl: string; + apiKey: string; + workspaceId: string; + projectId: string; + modelId: string; + valueFilters?: string; +}): Promise => { + let allItems: Item[] = []; + + // Fetch Assets with pagination + let assets: Asset[]; + try { + const apiBaseUrl = `${baseUrl}/${workspaceId}/projects/${projectId}/assets`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + // First request to get total count + const firstResponse = await fetch(`${apiBaseUrl}?perPage=100&page=1`, { + method: "GET", + headers, + }); + + if (!firstResponse.ok) { + throw new Error(`HTTP error! status: ${firstResponse.status}`); + } + + const firstData = await firstResponse.json(); + const totalCount = firstData.totalCount; + const perPage = 100; + const totalPages = Math.ceil(totalCount / perPage); + + assets = firstData.items || []; + + // Fetch remaining pages if there are more + if (totalPages > 1) { + const pagePromises = []; + for (let page = 2; page <= totalPages; page++) { + pagePromises.push( + fetch(`${apiBaseUrl}?perPage=${perPage}&page=${page}`, { + method: "GET", + headers, + }) + ); + } + + const responses = await Promise.all(pagePromises); + + for (const response of responses) { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + assets = assets.concat(data.items || []); + } + } + } catch (error) { + console.error("Error fetching assets:", error); + } + + // Fetch Schema + let schema: Schema; + try { + const url = `${baseUrl}/${workspaceId}/projects/${projectId}/models/${modelId}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + schema = data.schema.fields; + } catch (error) { + console.error("Error fetching schema:", error); + } + + // Fetch Items with pagination + try { + const apiBaseUrl = `${baseUrl}/${workspaceId}/projects/${projectId}/models/${modelId}/items`; + + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }; + + // First request to get total count + const firstResponse = await fetch(`${apiBaseUrl}?perPage=100&page=1`, { + method: "GET", + headers, + }); + + if (!firstResponse.ok) { + throw new Error(`HTTP error! status: ${firstResponse.status}`); + } + + const firstData = await firstResponse.json(); + const totalCount = firstData.totalCount; + const perPage = 100; + const totalPages = Math.ceil(totalCount / perPage); + + allItems = firstData.items || []; + + // Fetch remaining pages if there are more + if (totalPages > 1) { + const pagePromises = []; + for (let page = 2; page <= totalPages; page++) { + pagePromises.push( + fetch(`${apiBaseUrl}?perPage=${perPage}&page=${page}`, { + method: "GET", + headers, + }) + ); + } + + const responses = await Promise.all(pagePromises); + + for (const response of responses) { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + allItems = allItems.concat(data.items || []); + } + } + + // append schema's name to each item + // replace asset id with asset url in each item + allItems = allItems.map((item: Item) => ({ + ...item, + fields: [ + ...item.fields.map((field: Field) => ({ + ...field, + name: schema.find((s) => s.key === field.key)?.name, + value: + field.type === "asset" && typeof field.value === "string" + ? assets.find((a) => a.id === field.value)?.url || field.value + : field.type === "asset" && Array.isArray(field.value) + ? (field.value as string[]) + .map( + (assetId) => + assets.find((a) => a.id === assetId)?.url || assetId + ) + .reverse() + : field.value, + })), + ], + })); + + // Apply value filters if any + // Example: status===published|reviewed;category===news + if (valueFilters) { + const filters = valueFilters.split(";").map((filter) => { + const [key, values] = filter.split("==="); + return { key, values: values.split("|") }; + }); + + allItems = allItems.filter((item) => + filters.every((filter) => { + const field = item.fields.find((f) => f.key === filter.key); + if (!field) return false; + return filter.values.includes(String(field.value)); + }) + ); + } + } catch (error) { + console.error("Error fetching data:", error); + } + + return allItems; +}; diff --git a/src/extensions/visualizer/main/utils/getItemsFromPublicAPI.ts b/src/extensions/visualizer/main/utils/getItemsFromPublicAPI.ts new file mode 100644 index 0000000..2d7d8c5 --- /dev/null +++ b/src/extensions/visualizer/main/utils/getItemsFromPublicAPI.ts @@ -0,0 +1,187 @@ +import { Field, Item } from "../hooks"; + +type PropertyObject = Record>; + +type PublicModelResponse = { + results: PropertyObject[]; + totalCount: number; +}; + +type SchemaProperty = { + title: string; + type: string; + format?: string; + items?: { + properties: Record; + }; +}; + +type Schema = { + properties: Record; +}; + +export const getItemsFromPublicAPI = async ({ + baseUrl, + workspaceId, + projectId, + modelId, + valueFilters, +}: { + baseUrl: string; + workspaceId: string; + projectId: string; + modelId: string; + valueFilters?: string; +}): Promise => { + const url = `${baseUrl}/p/${workspaceId}/${projectId}/${modelId}`; + + const items: Item[] = []; + let schema: Schema | null = null; + let data: PublicModelResponse | null = null; + + // Get public model schema + try { + const schemaResponse = await fetch(`${url}.schema.json`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!schemaResponse.ok) { + throw new Error(`HTTP error! status: ${schemaResponse.status}`); + } + + schema = (await schemaResponse.json()) as Schema; + } catch (error) { + console.error("Error fetching schema from Public API:", error); + } + + // Get public model data + try { + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + data = (await response.json()) as PublicModelResponse; + } catch (error) { + console.error("Error fetching data from Public API:", error); + } + + if (!data || !schema) { + return items; + } + + const flatSchemaProperties: Record = {}; + + // Flatten schema properties + const flattenProperties = (properties: Record) => { + for (const [key, prop] of Object.entries(properties)) { + if (prop.items) { + flatSchemaProperties[key] = { + title: prop.title, + type: "group", + }; + flattenProperties(prop.items.properties); + } else { + flatSchemaProperties[key] = { + ...prop, + type: + prop.type === "string" && prop.format === "binary" + ? "asset" + : prop.type, + }; + } + } + }; + + flattenProperties(schema.properties); + + const processProperties = ( + properties: PropertyObject, + item: Item, + groupId?: string + ) => { + for (const [key, value] of Object.entries(properties)) { + if (key === "id") { + if (!groupId && value) { + item.id = value as string; + } + } else { + const fieldId = Math.random().toString(36).substring(2, 9); + + // check if it is a group + const type = flatSchemaProperties[key]?.type; + const field: Field = + type === "group" + ? { + id: fieldId, + key, + value: fieldId, + type, + name: flatSchemaProperties[key]?.title || key, + } + : // support array of assets only + Array.isArray(value) && + value.length > 0 && + value[0].type === "asset" + ? { + id: fieldId, + key, + value: value.map((v) => v.url), + type: value[0].type, + name: flatSchemaProperties[key]?.title || key, + } + : { + id: fieldId, + key, + value, + type: flatSchemaProperties[key]?.type, + name: flatSchemaProperties[key]?.title || key, + }; + + if (groupId) field.group = groupId; + + item.fields.push(field); + + if (type === "group") { + processProperties(value as Record, item, fieldId); + } + } + } + }; + + // Convert data results to items + for (const itemData of data.results) { + const item: Item = { id: "", fields: [] }; + processProperties(itemData, item); + items.push(item); + } + + // Apply value filters if any + // Example: status===published|reviewed;category===news + let filteredItems = items; + if (valueFilters) { + const filters = valueFilters.split(";").map((filter) => { + const [key, values] = filter.split("==="); + return { key, values: values.split("|") }; + }); + + filteredItems = items.filter((item) => + filters.every((filter) => { + const field = item.fields.find((f) => f.key === filter.key); + if (!field) return false; + return filter.values.includes(String(field.value)); + }) + ); + } + + return filteredItems; +}; diff --git a/src/extensions/visualizer/main/utils/getItemsFromServer.ts b/src/extensions/visualizer/main/utils/getItemsFromServer.ts new file mode 100644 index 0000000..dbced43 --- /dev/null +++ b/src/extensions/visualizer/main/utils/getItemsFromServer.ts @@ -0,0 +1,25 @@ +import { Item } from "../hooks"; + +export const getItemsFromServer = async ({ + baseUrl, + apiKey, +}: { + baseUrl: string; + apiKey: string; +}): Promise => { + const url = `${baseUrl}/items`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.data?.items || []; +};