import SDKAdapter from '../core/adapter.js' import { SDKModel } from '../core/model.js' import { SDKModelSchema } from '../core/schema.js' import { nanoid } from '../core/transport.js' import { SDKCollection } from '../index.js' import datasourceSchema from '../schemas/datasource.json' assert { type: 'json' } import type { SDK } from '../sdk.js' import { isDeepEquals, isObject, objectKeysShallowCompare } from '../utils/object.js' import { Atleast, RequiredKeys } from '../utils/typescript.js' import { LibraryModel, NanoId } from './libraries.js' export type DataSourceId = string // Fully resolved data - object or array export type Data = Record | any[] // Data keyed by datasource id export type DataScopes = Data | Record // Data options/samples indexed by id export type DataOptionsById = Record // Data options combines of the above: data, settings, plain or keyed by datasource id export type DataOptions = Data | DataSettings | DataOptionsById // Data settings is json object representing fetch call export type DataSettings = { params: { [key: string]: any } headers: { [key: string]: string } jsonpath: string method: 'GET' | 'POST' | 'OPTIONS' | 'PUT' | 'DELETE' | 'UPDATE' | 'HEAD' body: { [key: string]: any } url: string } export function DataSettings(settings: any): DataSettings { const headers = {} as any for (const key in settings?.headers || {}) { if (settings.headers.hasOwnProperty(key)) { const headerCase = key.replace(/^[a-z]|\-[a-z]/g, (m) => m.toUpperCase()) headers[headerCase] = settings.headers[key] } } if (!headers.Accept) headers['Accept'] = 'application/json' if (!headers['Content-Type']) headers['Content-Type'] = 'application/json' return { params: settings?.params || {}, headers: headers, jsonpath: String(settings?.jsonpath || '$'), method: (settings?.method || 'GET').toUpperCase() as | 'GET' | 'POST' | 'OPTIONS' | 'PUT' | 'DELETE' | 'UPDATE' | 'HEAD', body: settings?.body || {}, url: String(settings?.url || '') } } /** Defines the schema for Datasource. */ export interface Datasource { /** The identifier of the Library that Datasource belongs to */ libraryId: NanoId /** The identifier of the Datasource */ id: NanoId /** * The name of the Datasource. * * @minLength 1 * @maxLength 128 */ name: string /** * The description of the Datasource. * * @minLength 0 * @maxLength 128 */ description: string /** A sample of the Datasource */ sample: object[] | object /** The timestamp the Datascource was created at */ createdAt?: Date /** The timestamp when the Datascource was last modified at */ modifiedAt?: Date /** * The timestamp when the Datascource was last sampled at. * * @nullable */ sampledAt?: Date /** * Definition is a data returned from external resource, that we use to extract sample data from. Having this data, we * can detect if upstream structure was updated. * * @nullable */ definition?: object | object[] /** * Definition of remote datasource for a conflicting datasource. * * @nullable */ conflictDefinition?: object | object[] /** * Datasource type (internal, xm template, etc.) * * @nullable */ type?: 'internal' | 'xmTemplate' | 'contentHubOne' | 'external' /** * Id of external source. Eg: Tenant name for XM Cloud datasources, Project Id for internal datasources etc. * * @nullable */ externalSourceId?: string /** Default settings for the datasource */ settings: DataSettings } export class Datasources extends SDKAdapter { fetch(datasource: Pick): Promise { const { libraryId } = datasource return this.sdk.fetchJSON(`/libraries/${libraryId}/datasources`) } get(datasource: Pick): Promise { const { libraryId, id } = datasource return this.sdk.fetchJSON(`/libraries/${libraryId}/datasources/${id}`) } async fetchUpdates(datasource: Pick): Promise { const [url, options] = toFetchArguments(datasource.settings) const sample = await this.sdk.proxyJSON(url as string, options) return { ...datasource, sample, sampledAt: new Date() } as Datasource } post(datasource: Atleast): Promise { const { libraryId } = datasource const definition = datasource.isInternal() ? datasource.sample : datasource.definition return this.sdk.fetchJSON(`/libraries/${libraryId}/datasources`, { method: 'POST', body: JSON.stringify({ ...datasource, definition }) }) } put(datasource: Atleast): Promise { return this.post(datasource) } delete(datasource: Pick): Promise { const { libraryId, id } = datasource return this.sdk.fetchJSON(`/libraries/${libraryId}/datasources/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'text/plain' } }) } } export class XmDatasources extends Datasources { async fetch(datasource: Pick): Promise { const { libraryId, externalSourceId } = datasource if (!externalSourceId) return [] return await this.sdk.fetchJSON(`/libraries/${libraryId}/tenants/${externalSourceId}/datasources`) } async fetchUpdates(datasource: Pick): Promise { const { id, libraryId, externalSourceId } = datasource if (!externalSourceId) return null return await this.sdk.fetchJSON( `/libraries/${libraryId}/tenants/${externalSourceId}/datasources/${id}/fetch-updates` ) } } export class ContentHubOneDatasources extends Datasources { async fetch(datasource: Pick): Promise { const { libraryId } = datasource return await this.sdk.fetchJSON(`/libraries/${libraryId}/datasources/content-hub-one`) } async fetchUpdates(datasource: Pick): Promise { const { id, libraryId } = datasource return await this.sdk.fetchJSON(`/libraries/${libraryId}/datasources/content-hub-one/${id}/fetch-updates`) } } export function getDatasourceDefaults(sdk: SDK) { const now = new Date() return { id: nanoid(10), sampledAt: null as Date, createdAt: now, modifiedAt: now, type: 'internal', definition: {}, conflictDefinition: null as object, settings: DataSettings({}) } } export type DatasourceMinimal = RequiredKeys< Datasource, typeof getDatasourceDefaults, T > export interface DatasourceImplicit { libraryId: LibraryModel['id'] library?: LibraryModel } export interface DatasourceParams extends Datasource, DatasourceImplicit {} export interface DatasourceModel extends DatasourceParams {} export class DatasourceModel extends SDKModel implements DatasourceParams { get adapter() { if (this.type === 'xmTemplate') return this.sdk.XmDatasources else if (this.type === 'contentHubOne') return this.sdk.ContentHubOneDatasources else return this.sdk.Datasources } async fetchUpdates() { return this.change(await this.adapter.fetchUpdates(this)) } getCollection() { return this.type === 'internal' || !this.type ? this.library.datasources : this.type === 'contentHubOne' ? this.library.contentHubOneDatasources : this.sdk.auth.tenant.datasources } getHiddenProps(): string[] { return ['library'] } getDefaults(sdk: SDK) { return getDatasourceDefaults(sdk) } isInternal() { return this.type === 'internal' } isExtended() { return this.sdk.renderingHost.registeredDatasources.some((e) => e.id === this.id && !e.isEqual(this)) } static loadFromSessionStorage(): Datasource[] { return JSON.parse(sessionStorage.getItem('feaas-external-datasources')) } static storeToSessionStorage(datasources: Datasource[]) { if (typeof sessionStorage != 'undefined') sessionStorage.setItem('feaas-external-datasources', JSON.stringify(datasources)) } static registeredDatasources: Datasource[] static setExternalDatasources(datasources: Datasource[]) { this.registeredDatasources = datasources } static updateExternalDatasources( datasources: SDKCollection, list: Datasource[] = this.registeredDatasources ) { datasources.setItems( list.map((item) => { return { settings: DataSettings({}), type: 'external', sample: item.sample, ...item } }) ) } getPath() { return `/libraries/${this.libraryId}/datasources/${this.id}` } isConflictingTowards(previousState: Datasource) { return DatasourceModel.isConflicting(this.sample, previousState.sample) } /** * Looks for changes in the generated sample of the definition of a datasource. It only looks for removal of fields * though. Addition of fields is not something that concerns us */ static isConflicting(currentSample: Datasource['sample'], previousSample: Datasource['sample']) { // if no changes at all, return early if (isDeepEquals(currentSample, previousSample)) return false // if both are arrays, just return true (do not proceed to fields checking). In case of root type change, return false if (Array.isArray(currentSample)) { return Array.isArray(previousSample) } // recursively check the object for deletion of fields const check = (previousSample: any, currentSample: any) => { for (const key in previousSample) { if (!currentSample?.[key]) return true else if (isObject(previousSample[key])) { if (check(previousSample[key], currentSample[key])) return true } } return false } return check(previousSample, currentSample) } static getTreeViewLeafState(previous: any, current: any) { if (!previous?.length && current?.length) { return 'new' } else if (!current?.length && previous?.length) { return 'removed' } else if (!objectKeysShallowCompare(previous, current)) { return 'changed' } else { return 'unchanged' } } static getSettingsFromDatasources(datasourceIds: string[], datasources: DatasourceModel[]) { if (datasourceIds.every((id) => datasources.find((d) => d.id == id)?.type == 'xmTemplate')) return null return datasources .filter((d) => datasourceIds.find((id) => d.id == id)) .reduce>((map, datasource) => { return Object.assign(map, { [datasource.id]: datasource.settings.url ? datasource.settings : datasource.sample }) }, {}) } static getExternalComponentDatasources(data: DataOptionsById, allDatasources: DatasourceModel[]) { const datasourceIds = data ? Object.keys(data).filter((key) => data[key]) : [] return allDatasources?.filter((d) => datasourceIds.includes(d.id)) || [] } static get schema(): SDKModelSchema { return new SDKModelSchema(DatasourceModel, datasourceSchema) } async copyToLibrary(library: LibraryModel) { return library.datasources.upsertItem(this).save() } } /** * Converts settings object into fetch function arguments. * * @param {any} settings - The settings object. * @returns {Array} - An array containing URL and options objects as fetch function arguments. */ export function toFetchArguments(settings?: any): Parameters { const { url, headers, params, method, body: payload } = DataSettings(settings) let body = undefined const contentType = headers['Content-Type'] if (method != 'GET' && method != 'HEAD') { if (contentType === 'application/json') body = JSON.stringify(payload) if (contentType === 'multipart/form-data') body = Object.keys(payload).reduce((form, key) => { form.append(key, (payload as any)[key]) return form }, new FormData()) if (contentType === 'application/x-www-form-urlencoded') body = new URLSearchParams(payload) } const query = Object.keys(params).length ? `?${new URLSearchParams(params)}` : '' return [ `${url}${query}`, { headers, body, method } ] }