UNPKG

terriajs

Version:

Geospatial data visualization platform.

880 lines (788 loc) 26 kB
import i18next from "i18next"; import { computed, toJS } from "mobx"; import { createTransformer } from "mobx-utils"; import filterOutUndefined from "../../../Core/filterOutUndefined"; import isDefined from "../../../Core/isDefined"; import { isJsonObject, isJsonString, JsonArray, JsonObject } from "../../../Core/Json"; import loadJson from "../../../Core/loadJson"; import runLater from "../../../Core/runLater"; import TerriaError from "../../../Core/TerriaError"; import AccessControlMixin from "../../../ModelMixins/AccessControlMixin"; import GroupMixin from "../../../ModelMixins/GroupMixin"; import ReferenceMixin from "../../../ModelMixins/ReferenceMixin"; import UrlMixin from "../../../ModelMixins/UrlMixin"; import ModelTraits from "../../../Traits/ModelTraits"; import MagdaDistributionFormatTraits from "../../../Traits/TraitsClasses/MagdaDistributionFormatTraits"; import MagdaReferenceTraits from "../../../Traits/TraitsClasses/MagdaReferenceTraits"; import CommonStrata from "../../Definition/CommonStrata"; import CreateModel from "../../Definition/CreateModel"; import createStratumInstance from "../../Definition/createStratumInstance"; import { BaseModel } from "../../Definition/Model"; import ModelPropertiesFromTraits from "../../Definition/ModelPropertiesFromTraits"; import StratumFromTraits from "../../Definition/StratumFromTraits"; import StratumOrder from "../../Definition/StratumOrder"; import updateModelFromJson from "../../Definition/updateModelFromJson"; import Terria from "../../Terria"; import CatalogMemberFactory from "../CatalogMemberFactory"; import proxyCatalogItemUrl from "../proxyCatalogItemUrl"; const magdaRecordStratum = "magda-record"; StratumOrder.addDefaultStratum(magdaRecordStratum); // If you want to supply headers sent for magda requests, supply them in // config parameters export interface MagdaReferenceHeaders { [key: string]: string; } export default class MagdaReference extends AccessControlMixin( UrlMixin(ReferenceMixin(CreateModel(MagdaReferenceTraits))) ) { static readonly defaultDistributionFormats: StratumFromTraits<MagdaDistributionFormatTraits>[] = [ createStratumInstance(MagdaDistributionFormatTraits, { id: "WMS", formatRegex: "^wms$", definition: { type: "wms" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "WMS-GROUP", formatRegex: "^wms-group$", definition: { type: "wms-group" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "EsriMapServer", formatRegex: "^esri (mapserver|map server|rest|tiled map service)$", urlRegex: "MapServer", definition: { type: "esri-mapServer" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "CSV", formatRegex: "^csv(-geo-)?", definition: { type: "csv" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "CZML", formatRegex: "^czml$", definition: { type: "czml" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "KML", formatRegex: "^km[lz]$", definition: { type: "kml" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "GeoJSON", formatRegex: "^geojson$", definition: { type: "geojson" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "WFS", formatRegex: "^wfs$", definition: { type: "wfs" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "EsriFeatureServer", formatRegex: "ESRI (MAPSERVER|FEATURESERVER)", // We still allow `ESRI MAPSERVER` to be considered for compatibility reason urlRegex: "FeatureServer$|FeatureServer/$", // url Regex will exclude MapServer urls definition: { type: "esri-featureServer-group" } }), createStratumInstance(MagdaDistributionFormatTraits, { id: "EsriFeatureServer", formatRegex: "ESRI (MAPSERVER|FEATURESERVER)", // We still allow `ESRI MAPSERVER` to be considered for compatibility reason urlRegex: "FeatureServer/d", definition: { type: "esri-featureServer" } }) ]; static readonly type = "magda"; get type() { return MagdaReference.type; } constructor( id: string | undefined, terria: Terria, sourceReference?: BaseModel, strata?: Map<string, StratumFromTraits<ModelTraits>> ) { super(id, terria, sourceReference, strata); this.setTrait( CommonStrata.defaults, "distributionFormats", MagdaReference.defaultDistributionFormats ); } @computed get registryUri(): uri.URI | undefined { const uri = this.uri; if (uri === undefined) { return undefined; } return uri.clone().segment("api/v0/registry"); } @computed get preparedDistributionFormats(): PreparedDistributionFormat[] { return ( this.distributionFormats && this.distributionFormats.map(prepareDistributionFormat) ); } @computed get accessType(): string { const access = getAccessTypeFromMagdaRecord(this.magdaRecord); return access || super.accessType; } protected async forceLoadReference( previousTarget: BaseModel | undefined ): Promise<BaseModel | undefined> { const existingRecord = this.magdaRecord ? toJS(this.magdaRecord) : undefined; const magdaUri = this.uri; const override = toJS(this.override); const addOrOverrideAspects = toJS(this.addOrOverrideAspects); const distributionFormats = this.preparedDistributionFormats; // `runLater` is needed due to no actions in `AsyncLoader` computed promise (See AsyncLoader.ts) return await runLater(async () => { const target = MagdaReference.createMemberFromRecord( this.terria, this, distributionFormats, magdaUri, this.uniqueId, existingRecord, override, previousTarget, addOrOverrideAspects ); if (target !== undefined) { return target; } const record = await this.loadMagdaRecord({ id: this.recordId, optionalAspects: [ "terria", "group", "dcat-dataset-strings", "dcat-distribution-strings", "dataset-distributions", "dataset-format" ], dereference: true, magdaReferenceHeaders: this.terria.configParameters.magdaReferenceHeaders }); return MagdaReference.createMemberFromRecord( this.terria, this, distributionFormats, magdaUri, this.uniqueId, record, override, previousTarget, addOrOverrideAspects ); }); } private static overrideRecordAspects( record: JsonObject | undefined, override: JsonObject | undefined ) { if (record && override && isJsonObject(override.aspects)) { if (isJsonObject(record.aspects)) { for (let key in override.aspects) record.aspects[key] = override.aspects[key]; } else { record.aspects = override.aspects; } } } static createMemberFromRecord( terria: Terria, sourceReference: BaseModel | undefined, distributionFormats: readonly PreparedDistributionFormat[], magdaUri: uri.URI | undefined, id: string | undefined, record: JsonObject | undefined, override: JsonObject | undefined, previousTarget: BaseModel | undefined, addOrOverrideAspects: JsonObject | undefined = undefined ): BaseModel | undefined { if (record === undefined) { return undefined; } this.overrideRecordAspects(record, addOrOverrideAspects); const aspects = record.aspects; if (!isJsonObject(aspects)) { return undefined; } if (isJsonObject(aspects.group) && Array.isArray(aspects.group.members)) { const members = aspects.group.members; if (members.every((member) => isJsonObject(member))) { // Every member has been dereferenced, so we're good to go. return MagdaReference.createGroupFromRecord( terria, sourceReference, distributionFormats, magdaUri, id, record, override, previousTarget ); } else { // Not enough information to create a group yet. return undefined; } } if (isJsonObject(aspects.terria) && isJsonString(aspects.terria.type)) { // A terria aspect is really all we need, _except_ if the terria aspect indicates // this is a group and we don't have a dereferenced group aspect to tell us what's // in the group. if (aspects.terria.type === "group") { // TODO: could be other types of groups! // If we had a dereferenced group aspect, we would have returned above. return undefined; } else { return MagdaReference.createMemberFromTerriaAspect( terria, sourceReference, magdaUri, id, record, aspects.terria, override, previousTarget ); } } // If this is a dataset, we need the distributions to be dereferenced. let distributions: JsonArray | undefined; if (isJsonObject(aspects["dcat-dataset-strings"])) { const datasetDistributions = aspects["dataset-distributions"]; if ( !isJsonObject(datasetDistributions) || !Array.isArray(datasetDistributions.distributions) ) { // Distributions not present return undefined; } distributions = datasetDistributions.distributions; if (!distributions.every((distribution) => isJsonObject(distribution))) { // Some of the distributions are not dereferenced. return undefined; } } // A distribution is already ready to go if (isJsonObject(aspects["dcat-distribution-strings"])) { distributions = distributions ? distributions.slice() : []; distributions.push(record); } if (distributions) { const match = MagdaReference.findPreparedDistributionFormat( distributionFormats, distributions ); if ( match !== undefined && match.format.definition && isJsonString(match.format.definition.type) ) { return MagdaReference.createMemberFromDistributionFormat( terria, sourceReference, magdaUri, id, record, match.distribution, match.format, override, previousTarget ); } } return undefined; } private static createGroupFromRecord( terria: Terria, sourceReference: BaseModel | undefined, distributionFormats: readonly PreparedDistributionFormat[], magdaUri: uri.URI | undefined, id: string | undefined, record: JsonObject, override: JsonObject | undefined, previousTarget: BaseModel | undefined ): BaseModel | undefined { const aspects = record.aspects; if (!isJsonObject(aspects)) { return undefined; } const terriaAspect = aspects.terria; const type = isJsonObject(terriaAspect) && isJsonString(terriaAspect.type) ? terriaAspect.type : "group"; const ModelClass = CatalogMemberFactory.find(type); if (ModelClass === undefined) { throw new TerriaError({ sender: this, title: i18next.t("models.catalog.unsupportedTypeTitle"), message: i18next.t("models.catalog.unsupportedTypeMessage", { type }) }); } let group: BaseModel; if (previousTarget && previousTarget instanceof ModelClass) { group = previousTarget; } else { group = new ModelClass(id, terria, sourceReference); } if (isJsonObject(aspects.group) && Array.isArray(aspects.group.members)) { const members = aspects.group.members; const ids = members.map((member) => { if (!isJsonObject(member) || !isJsonString(member.id)) { return undefined; } const memberId = member.id; let overriddenMember: JsonObject | undefined; if (override && Array.isArray(override.members)) { overriddenMember = override.members.find( (member) => isJsonObject(member) && member.id === memberId ) as JsonObject | undefined; } const model = MagdaReference.createMemberFromRecord( terria, undefined, distributionFormats, magdaUri, member.id, member, overriddenMember, terria.getModelById(BaseModel, member.id) ); let shareKeys; if ( isJsonObject(member.aspects, false) && isJsonObject(member.aspects.terria, false) && Array.isArray(member.aspects.terria.shareKeys) ) { shareKeys = member.aspects.terria.shareKeys.filter(isJsonString); } if (!model) { // Can't create an item or group yet, so create a reference. const ref = new MagdaReference(member.id, terria, undefined); if (magdaUri) { ref.setTrait(CommonStrata.definition, "url", magdaUri.toString()); } ref.setTrait(CommonStrata.definition, "recordId", memberId); if ( isJsonObject(member.aspects, false) && isJsonObject(member.aspects.group, false) ) { // This is most likely a group. ref.setTrait(CommonStrata.definition, "isGroup", true); } else { // This is most likely a mappable or chartable item. ref.setTrait(CommonStrata.definition, "isMappable", true); ref.setTrait(CommonStrata.definition, "isChartable", true); } // Use the name from the terria aspect if there is one. if ( isJsonObject(member.aspects, false) && isJsonObject(member.aspects.terria, false) && isJsonObject(member.aspects.terria.definition, false) && isJsonString(member.aspects.terria.definition.name) ) { ref.setTrait( CommonStrata.definition, "name", member.aspects.terria.definition.name ); } else if (isJsonString(member.name)) { ref.setTrait(CommonStrata.definition, "name", member.name); } if (overriddenMember) { ref.setTrait(CommonStrata.definition, "override", overriddenMember); } if (terria.getModelById(BaseModel, member.id) === undefined) { terria.addModel(ref, shareKeys); } if (AccessControlMixin.isMixedInto(ref)) { ref.setAccessType(getAccessTypeFromMagdaRecord(member)); } return ref.uniqueId; } else { if (terria.getModelById(BaseModel, member.id) === undefined) { terria.addModel(model, shareKeys); } if (AccessControlMixin.isMixedInto(model)) { model.setAccessType(getAccessTypeFromMagdaRecord(member)); } return model.uniqueId; } }); if (isJsonString(record.name)) { group.setTrait(magdaRecordStratum, "name", record.name); } group.setTrait(magdaRecordStratum, "members", filterOutUndefined(ids)); if (GroupMixin.isMixedInto(group)) { console.log(`Refreshing ids for ${group.uniqueId}`); group.refreshKnownContainerUniqueIds(group.uniqueId); } } if (isJsonObject(aspects.terria, false)) { const terriaAspect = aspects.terria; Object.keys(terriaAspect).forEach((key) => { const terriaStratum = terriaAspect[key]; if ( key === "id" || key === "type" || key === "shareKeys" || !isJsonObject(terriaStratum, false) ) { return; } updateModelFromJson(group, key, terriaStratum, true).logError(); }); } if (override) { updateModelFromJson( group, CommonStrata.override, override, true ).logError(); } return group; } private static createMemberFromTerriaAspect( terria: Terria, sourceReference: BaseModel | undefined, magdaUri: uri.URI | undefined, id: string | undefined, record: JsonObject, terriaAspect: JsonObject, override: JsonObject | undefined, previousTarget: BaseModel | undefined ): BaseModel | undefined { if (!isJsonString(terriaAspect.type)) { return undefined; } let result: BaseModel; if (previousTarget && previousTarget.type === terriaAspect.type) { result = previousTarget; } else { // Couldn't re-use the previous target, so create a new one. const newMember = CatalogMemberFactory.create( terriaAspect.type, id, terria, sourceReference ); if (newMember === undefined) { console.error( `Could not create unknown model type ${terriaAspect.type}.` ); // don't create a stub here, as magda should rarely create unknown model types // and we'll let the UI highlight that it's bad rather than bandaging an unknown type return undefined; } result = newMember; } if (isJsonString(record.name)) { result.setTrait(magdaRecordStratum, "name", record.name); } Object.keys(terriaAspect).forEach((key) => { const terriaStratum = terriaAspect[key]; if ( key === "id" || key === "type" || key === "shareKeys" || !isJsonObject(terriaStratum, false) ) { return; } updateModelFromJson(result, key, terriaStratum, true).catchError( (error) => { error.log(); result.setTrait(CommonStrata.underride, "isExperiencingIssues", true); } ); }); if (override) { updateModelFromJson( result, CommonStrata.override, override, true ).logError(); } return result; } private static createMemberFromDistributionFormat( terria: Terria, sourceReference: BaseModel | undefined, magdaUri: uri.URI | undefined, id: string | undefined, datasetRecord: JsonObject, distributionRecord: JsonObject, format: PreparedDistributionFormat, override: JsonObject | undefined, previousTarget: BaseModel | undefined ): BaseModel | undefined { if ( !isJsonString(format.definition.type) || !isJsonObject(datasetRecord.aspects) || !isJsonObject(distributionRecord.aspects) ) { return undefined; } let result: BaseModel; if (previousTarget && previousTarget.type === format.definition.type) { result = previousTarget; } else { // Couldn't re-use the previous target, so create a new one. const newMember = CatalogMemberFactory.create( format.definition.type, id, terria, sourceReference ); if (newMember === undefined) { throw new TerriaError({ sender: this, title: i18next.t("models.catalog.unsupportedTypeTitle"), message: i18next.t("models.catalog.unsupportedTypeMessage", { type: format.definition.type }) }); } result = newMember; } const datasetDcat = datasetRecord.aspects["dcat-dataset-strings"]; const distributionDcat = distributionRecord.aspects["dcat-distribution-strings"]; let url: string | undefined; if (isJsonObject(distributionDcat)) { if (isJsonString(distributionDcat.downloadURL)) { url = distributionDcat.downloadURL; } if (url === undefined && isJsonString(distributionDcat.accessURL)) { url = distributionDcat.accessURL; } } const definition: any = { url: url, info: [], ...format.definition }; if ( isJsonObject(datasetDcat) && isJsonString(datasetDcat.description) && !definition.info.find( (section: any) => section.name === "Dataset Description" ) ) { definition.info.push({ name: "Dataset Description", content: datasetDcat.description }); } if ( isJsonObject(distributionDcat) && isJsonString(distributionDcat.description) && !definition.info.find( (section: any) => section.name === "Distribution Description" ) ) { definition.info.push({ name: "Distribution Description", content: distributionDcat.description }); } if (isJsonString(datasetRecord.name)) updateModelFromJson( result, magdaRecordStratum, { name: datasetRecord.name }, true ).logError(); updateModelFromJson( result, CommonStrata.definition, definition, true ).logError(); if (override) { updateModelFromJson( result, CommonStrata.override, override, true ).logError(); } return result; } private static findPreparedDistributionFormat( distributionFormats: readonly PreparedDistributionFormat[], distributions: JsonArray ): | { distribution: JsonObject; format: PreparedDistributionFormat; } | undefined { for (let i = 0; i < distributionFormats.length; ++i) { const distributionFormat = distributionFormats[i]; const formatRegex = distributionFormat.formatRegex; const urlRegex = distributionFormat.urlRegex; // Find distributions that match this format for (let j = 0; j < distributions.length; ++j) { const distribution = distributions[j]; if (!isJsonObject(distribution)) { continue; } const aspects = distribution.aspects; if (!isJsonObject(aspects)) { continue; } const dcatJson = aspects["dcat-distribution-strings"]; const datasetFormat = aspects["dataset-format"]; let format: string | undefined; let url: string | undefined; if (isJsonObject(dcatJson)) { if (typeof dcatJson.format === "string") { format = dcatJson.format; } if (typeof dcatJson.downloadURL === "string") { url = dcatJson.downloadURL; } if (url === undefined && typeof dcatJson.accessURL === "string") { url = dcatJson.accessURL; } } if ( isJsonObject(datasetFormat) && typeof datasetFormat.format === "string" ) { format = datasetFormat.format; } if (format === undefined || url === undefined) { continue; } if ( (formatRegex !== undefined && !formatRegex.test(format)) || (urlRegex !== undefined && !urlRegex.test(url)) ) { continue; } // We have a match! return { distribution: distribution, format: distributionFormat }; } } return undefined; } @computed get cacheDuration(): string { if (isDefined(super.cacheDuration)) { return super.cacheDuration; } return "0d"; } protected loadMagdaRecord(options: RecordOptions): Promise<JsonObject> { const recordUri = this.buildMagdaRecordUri(options); if (recordUri === undefined) { return Promise.reject( new TerriaError({ sender: this, title: i18next.t("models.magda.idsNotSpecifiedTitle"), message: i18next.t("models.magda.idsNotSpecifiedMessage") }) ); } const proxiedUrl = proxyCatalogItemUrl(this, recordUri.toString()); return loadJson(proxiedUrl, options.magdaReferenceHeaders); } protected buildMagdaRecordUri(options: RecordOptions): uri.URI | undefined { const registryUri = this.registryUri; if (options.id === undefined || registryUri === undefined) { return undefined; } const recordUri = registryUri .clone() .segment(`records/${encodeURIComponent(options.id)}`); if (options.aspects) { recordUri.addQuery("aspect", options.aspects); } if (options.optionalAspects) { recordUri.addQuery("optionalAspect", options.optionalAspects); } if (options.dereference) { recordUri.addQuery("dereference", "true"); } return recordUri; } } export interface RecordOptions { id: string | undefined; aspects?: string[]; optionalAspects?: string[]; dereference?: boolean; magdaReferenceHeaders?: MagdaReferenceHeaders; } interface PreparedDistributionFormat { formatRegex: RegExp | undefined; urlRegex: RegExp | undefined; definition: JsonObject; } const prepareDistributionFormat = createTransformer( (format: ModelPropertiesFromTraits<MagdaDistributionFormatTraits>) => { return { formatRegex: format.formatRegex ? new RegExp(format.formatRegex, "i") : undefined, urlRegex: format.urlRegex ? new RegExp(format.urlRegex, "i") : undefined, definition: format.definition || {} }; } ); function getAccessTypeFromMagdaRecord(magdaRecord: any): string { const record = toJS(magdaRecord); // Magda V2 access control has higher priority. if (record?.aspects?.["access-control"]) { return record.aspects["access-control"].orgUnitId ? record.aspects["access-control"].constraintExemption ? "public" : "non-public" : "public"; } else if (record?.aspects?.["esri-access-control"]) { return record.aspects["esri-access-control"].access; } else { return "public"; } }