terriajs
Version:
Geospatial data visualization platform.
498 lines (448 loc) • 16.3 kB
text/typescript
import i18next from "i18next";
import { computed, makeObservable, override, runInAction } from "mobx";
import combine from "terriajs-cesium/Source/Core/combine";
import {
FeatureCollectionWithCrs,
toFeatureCollection
} from "../../../Core/GeoJson";
import TerriaError from "../../../Core/TerriaError";
import containsAny from "../../../Core/containsAny";
import isDefined from "../../../Core/isDefined";
import isReadOnlyArray from "../../../Core/isReadOnlyArray";
import loadText from "../../../Core/loadText";
import gmlToGeoJson from "../../../Map/Vector/gmlToGeoJson";
import { getName } from "../../../ModelMixins/CatalogMemberMixin";
import GeoJsonMixin from "../../../ModelMixins/GeojsonMixin";
import GetCapabilitiesMixin from "../../../ModelMixins/GetCapabilitiesMixin";
import xml2json from "../../../ThirdParty/xml2json";
import { InfoSectionTraits } from "../../../Traits/TraitsClasses/CatalogMemberTraits";
import { RectangleTraits } from "../../../Traits/TraitsClasses/MappableTraits";
import WebFeatureServiceCatalogItemTraits, {
SUPPORTED_CRS_3857,
SUPPORTED_CRS_4326
} from "../../../Traits/TraitsClasses/WebFeatureServiceCatalogItemTraits";
import CreateModel from "../../Definition/CreateModel";
import LoadableStratum from "../../Definition/LoadableStratum";
import { BaseModel, ModelConstructorParameters } from "../../Definition/Model";
import StratumFromTraits from "../../Definition/StratumFromTraits";
import createStratumInstance from "../../Definition/createStratumInstance";
import proxyCatalogItemUrl from "../proxyCatalogItemUrl";
import WebFeatureServiceCapabilities, {
FeatureType,
getRectangleFromLayer
} from "./WebFeatureServiceCapabilities";
export class GetCapabilitiesStratum extends LoadableStratum(
WebFeatureServiceCatalogItemTraits
) {
static async load(
catalogItem: WebFeatureServiceCatalogItem,
capabilities?: WebFeatureServiceCapabilities
): Promise<GetCapabilitiesStratum> {
if (!isDefined(catalogItem.getCapabilitiesUrl)) {
throw new TerriaError({
title: i18next.t("models.webFeatureServiceCatalogItem.missingUrlTitle"),
message: i18next.t(
"models.webFeatureServiceCatalogItem.missingUrlMessage"
)
});
}
if (!isDefined(capabilities))
capabilities = await WebFeatureServiceCapabilities.fromUrl(
proxyCatalogItemUrl(
catalogItem,
catalogItem.getCapabilitiesUrl,
catalogItem.getCapabilitiesCacheDuration
)
);
return new GetCapabilitiesStratum(catalogItem, capabilities);
}
constructor(
readonly catalogItem: WebFeatureServiceCatalogItem,
readonly capabilities: WebFeatureServiceCapabilities
) {
super();
makeObservable(this);
}
duplicateLoadableStratum(model: BaseModel): this {
return new GetCapabilitiesStratum(
model as WebFeatureServiceCatalogItem,
this.capabilities
) as this;
}
get capabilitiesFeatureTypes(): ReadonlyMap<string, FeatureType | undefined> {
const lookup: (name: string) => [string, FeatureType | undefined] = (
name
) => [name, this.capabilities && this.capabilities.findLayer(name)];
return new Map(this.catalogItem.typeNamesArray.map(lookup));
}
get info(): StratumFromTraits<InfoSectionTraits>[] {
const result: StratumFromTraits<InfoSectionTraits>[] = [
createStratumInstance(InfoSectionTraits, {
name: i18next.t(
"models.webFeatureServiceCatalogItem.getCapabilitiesUrl"
),
content: this.catalogItem.getCapabilitiesUrl
})
];
let firstDataDescription: string | undefined;
for (const layer of this.capabilitiesFeatureTypes.values()) {
if (
!layer ||
!layer.Abstract ||
containsAny(
layer.Abstract,
WebFeatureServiceCatalogItem.abstractsToIgnore
)
) {
continue;
}
const suffix =
this.capabilitiesFeatureTypes.size === 1 ? "" : ` - ${layer.Title}`;
const name = `${i18next.t(
"models.webFeatureServiceCatalogItem.abstract"
)}${suffix}`;
result.push(
createStratumInstance(InfoSectionTraits, {
name,
content: layer.Abstract
})
);
firstDataDescription = firstDataDescription || layer.Abstract;
}
// Show the service abstract if there is one and if it isn't the Geoserver default "A compliant implementation..."
const service = this.capabilities && this.capabilities.service;
if (service) {
if (
service &&
service.Abstract &&
!containsAny(
service.Abstract,
WebFeatureServiceCatalogItem.abstractsToIgnore
) &&
service.Abstract !== firstDataDescription
) {
result.push(
createStratumInstance(InfoSectionTraits, {
name: i18next.t("models.webFeatureServiceCatalogItem.abstract"),
content: service.Abstract
})
);
}
// Show the Access Constraints if it isn't "none" (because that's the default, and usually a lie).
if (
service.AccessConstraints &&
!/^none$/i.test(service.AccessConstraints)
) {
result.push(
createStratumInstance(InfoSectionTraits, {
name: i18next.t(
"models.webFeatureServiceCatalogItem.accessConstraints"
),
content: service.AccessConstraints
})
);
}
}
return result;
}
get infoSectionOrder(): string[] {
let layerDescriptions = [
i18next.t("models.webFeatureServiceCatalogItem.abstract")
];
// If more than one layer, push layer description titles for each applicable layer
if (this.capabilitiesFeatureTypes.size > 1) {
layerDescriptions = [];
this.capabilitiesFeatureTypes.forEach((layer) => {
if (
layer &&
layer.Abstract &&
!containsAny(
layer.Abstract,
WebFeatureServiceCatalogItem.abstractsToIgnore
)
) {
layerDescriptions.push(
`${i18next.t("models.webFeatureServiceCatalogItem.abstract")} - ${
layer.Title
}`
);
}
});
}
return [
i18next.t("preview.disclaimer"),
i18next.t("description.name"),
...layerDescriptions,
i18next.t("preview.datasetDescription"),
i18next.t("preview.serviceDescription"),
i18next.t("models.webFeatureServiceCatalogItem.serviceDescription"),
i18next.t("preview.resourceDescription"),
i18next.t("preview.licence"),
i18next.t("preview.accessConstraints"),
i18next.t("models.webFeatureServiceCatalogItem.accessConstraints"),
i18next.t("preview.author"),
i18next.t("preview.contact"),
i18next.t("models.webFeatureServiceCatalogItem.serviceContact"),
i18next.t("preview.created"),
i18next.t("preview.modified"),
i18next.t("preview.updateFrequency"),
i18next.t("models.webFeatureServiceCatalogItem.getCapabilitiesUrl")
];
}
get rectangle(): StratumFromTraits<RectangleTraits> | undefined {
const layers: FeatureType[] = [
...this.capabilitiesFeatureTypes.values()
].filter(isDefined);
// Only return first layer's rectangle - as we don't support multiple WFS layers
return layers.length > 0 ? getRectangleFromLayer(layers[0]) : undefined;
}
get isGeoServer(): boolean | undefined {
if (!this.capabilities) {
return undefined;
}
if (
!this.capabilities.service ||
!this.capabilities.service.KeywordList ||
!this.capabilities.service.KeywordList.Keyword
) {
return false;
}
const keyword = this.capabilities.service.KeywordList.Keyword;
if (isReadOnlyArray(keyword)) {
return keyword.indexOf("GEOSERVER") >= 0;
} else {
return keyword === "GEOSERVER";
}
}
// Helper function to check if geojson output is supported (by checking GetCapabilities OutputTypes OR FeatureType OutputTypes)
hasJsonOutputFormat = (outputFormats: string[] | undefined) => {
return isDefined(
outputFormats?.find((format) =>
["json", "JSON", "application/json"].includes(format)
)
);
};
// Find which GML formats are supported, choose the one most suited to Terria. If not available, default to "gml3"
get outputFormat(): string | undefined {
const supportsGeojson =
this.hasJsonOutputFormat(this.capabilities.outputTypes) ||
[...this.capabilitiesFeatureTypes.values()].reduce<boolean>(
(hasGeojson, current) =>
hasGeojson && this.hasJsonOutputFormat(current?.OutputFormats),
true
);
const searchValue = new RegExp(".*gml/3.1.1.*|.*gml3.1.1.*");
return supportsGeojson
? "JSON"
: (this.capabilities.outputTypes?.find((outputFormat) =>
searchValue.test(outputFormat)
) ?? "gml3");
}
/** Finds the best srsName to use.
* First checks if one provided in url. If one is provided in url, and this is supported by Terria, will use this.
* Note that an error will be thrown if user supplied srsName is not supported by the user supplied WFS service.
* If no srsName provided, or the provided one is not supported by Terria,
* then checks getCapabilities response and returns the first listed srs that is included in our list of supported srs.
* This enables us to use a urn identifier if supported, or a normal EPSG code if not.
* e.g. "urn:ogc:def:crs:EPSG::4326" or "EPSG:4326"
**/
get srsName(): string | undefined {
// First check to see if URL has CRS or SRS
const supportedCrs = [...SUPPORTED_CRS_3857, ...SUPPORTED_CRS_4326];
const queryParams: any = this.catalogItem.uri?.query(true) ?? {};
const urlCrs =
queryParams.srsName ??
queryParams.crs ??
queryParams.CRS ??
queryParams.srs ??
queryParams.SRS;
if (urlCrs && supportedCrs.includes(urlCrs)) return urlCrs;
// If no srsName provided, then find what the server supports and use the best one for Terria
const layerSrsArray = this.capabilities.srsNames?.find(
(layer) => layer.layerName === this.catalogItem.typeNamesArray[0] //If multiple layers in this WFS request, only use the first layer to find best srsName
);
return (
layerSrsArray?.srsArray.find((srsName) =>
SUPPORTED_CRS_4326.includes(srsName)
) ?? "urn:ogc:def:crs:EPSG::4326" // Default to urn identifier for WGS84 if we cant find something better. Sometimes WFS service will support this even if not specified in GetCapabilities response.
);
}
}
class WebFeatureServiceCatalogItem extends GetCapabilitiesMixin(
GeoJsonMixin(CreateModel(WebFeatureServiceCatalogItemTraits))
) {
/**
* The collection of strings that indicate an Abstract property should be ignored. If these strings occur anywhere
* in the Abstract, the Abstract will not be used. This makes it easy to filter out placeholder data like
* Geoserver's "A compliant implementation of WFS..." stock abstract.
*/
static abstractsToIgnore = [
"A compliant implementation of WFS",
"This is the reference implementation of WFS 1.0.0 and WFS 1.1.0, supports all WFS operations including Transaction."
];
// hide elements in the info section which might show information about the datasource
_sourceInfoItemNames = [
i18next.t("models.webFeatureServiceCatalogItem.getCapabilitiesUrl")
];
static readonly type = "wfs";
constructor(...args: ModelConstructorParameters) {
super(...args);
makeObservable(this);
}
get type() {
return WebFeatureServiceCatalogItem.type;
}
protected get defaultGetCapabilitiesUrl(): string | undefined {
if (this.uri) {
return this.uri
.clone()
.setSearch({
service: "WFS",
version: "1.1.0",
request: "GetCapabilities"
})
.toString();
} else {
return undefined;
}
}
async createGetCapabilitiesStratumFromParent(
capabilities: WebFeatureServiceCapabilities
) {
const stratum = await GetCapabilitiesStratum.load(this, capabilities);
runInAction(() => {
this.strata.set(GetCapabilitiesMixin.getCapabilitiesStratumName, stratum);
});
}
protected async forceLoadMetadata(): Promise<void> {
if (
this.strata.get(GetCapabilitiesMixin.getCapabilitiesStratumName) !==
undefined
)
return;
const stratum = await GetCapabilitiesStratum.load(this);
runInAction(() => {
this.strata.set(GetCapabilitiesMixin.getCapabilitiesStratumName, stratum);
});
}
protected async forceLoadGeojsonData(): Promise<FeatureCollectionWithCrs> {
const getCapabilitiesStratum: GetCapabilitiesStratum | undefined =
this.strata.get(
GetCapabilitiesMixin.getCapabilitiesStratumName
) as GetCapabilitiesStratum;
if (!this.uri) {
throw new TerriaError({
sender: this,
title: i18next.t("models.webFeatureServiceCatalogItem.missingUrlTitle"),
message: i18next.t(
"models.webFeatureServiceCatalogItem.missingUrlMessage",
this
)
});
}
// Check if layers exist
const missingLayers = this.typeNamesArray.filter(
(layer) =>
!isDefined(getCapabilitiesStratum.capabilitiesFeatureTypes.get(layer))
);
if (missingLayers.length > 0) {
throw new TerriaError({
sender: this,
title: i18next.t(
"models.webFeatureServiceCatalogItem.noLayerFoundTitle"
),
message: i18next.t(
"models.webFeatureServiceCatalogItem.noLayerFoundMessage",
{ name: getName(this), typeNames: missingLayers.join(", ") }
)
});
}
const url = this.uri
.clone()
.setSearch(
combine(
{
service: "WFS",
request: "GetFeature",
typeName: this.typeNames,
version: "1.1.0",
outputFormat: this.outputFormat,
srsName: this.srsName,
maxFeatures: this.maxFeatures
},
this.parameters
)
)
.toString();
const getFeatureResponse = await loadText(proxyCatalogItemUrl(this, url));
// Check for errors (if supportsGeojson and the request returns XML, OR the response includes ExceptionReport)
if (
(this.outputFormat === "JSON" && getFeatureResponse.startsWith("<")) ||
getFeatureResponse.includes("ExceptionReport")
) {
let errorMessage: string | undefined;
try {
errorMessage = xml2json(getFeatureResponse).Exception?.ExceptionText;
} catch {}
const originalError = isDefined(errorMessage)
? new TerriaError({
sender: this,
title: "Exception from service",
message: errorMessage
})
: undefined;
throw new TerriaError({
sender: this,
title: i18next.t(
"models.webFeatureServiceCatalogItem.missingDataTitle"
),
message: `${i18next.t(
"models.webFeatureServiceCatalogItem.missingDataMessage",
{ name: getName(this) }
)}`,
originalError
});
}
const geojsonData =
this.outputFormat === "JSON"
? JSON.parse(getFeatureResponse)
: gmlToGeoJson(getFeatureResponse);
const fc = toFeatureCollection(geojsonData);
if (fc) return fc;
throw TerriaError.from(
"Invalid geojson data - only FeatureCollection and Feature are supported"
);
}
get typeNamesArray(): ReadonlyArray<string> {
if (Array.isArray(this.typeNames)) {
return this.typeNames;
} else if (this.typeNames) {
return this.typeNames.split(",");
} else {
return [];
}
}
get shortReport(): string | undefined {
// Show notice if reached
if (
this.readyData?.features !== undefined &&
this.readyData!.features.length >= this.maxFeatures
) {
return i18next.t(
"models.webFeatureServiceCatalogItem.reachedMaxFeatureLimit",
this
);
}
return undefined;
}
}
export default WebFeatureServiceCatalogItem;