"use strict";
/*global require*/
var CatalogFunction = require("./CatalogFunction");
var CatalogGroup = require("./CatalogGroup");
var clone = require("terriajs-cesium/Source/Core/clone").default;
var createParameterFromType = require("./createParameterFromType");
var defined = require("terriajs-cesium/Source/Core/defined").default;
var inherit = require("../Core/inherit");
var knockout = require("terriajs-cesium/Source/ThirdParty/knockout").default;
var proxyCatalogItemUrl = require("./proxyCatalogItemUrl");
var ResultPendingCatalogItem = require("./ResultPendingCatalogItem");
var sprintf = require("terriajs-cesium/Source/ThirdParty/sprintf").default;
var URI = require("urijs");
var Resource = require("terriajs-cesium/Source/Core/Resource").default;
var TerriaError = require("../Core/TerriaError");
var i18next = require("i18next").default;
/**
* A {@link CatalogFunction} that issues an HTTP GET to a service with a set of query parameters specified by the
* {@link TerriaJsonCatalogFunction#inputs} property, and expects to receive back TerriaJS catalog/share JSON.
*
* When this `CatalogFunction` is added to the catalog, TerriaJS automatically creates a user interface for it
* based on the inputs. When the user clicks "Run Analysis", it issues an HTTP GET with the user-specified
* inputs supplied as part of the query string. The returned TerriaJS catalog/share JSON can add items
* to the workbench, configure the catalog, change the camera view, and more.
*
* Example:
*
* ```
* {
* "name": "Simple Example",
* "type": "terria-json",
* "url": "https://putsreq.com/PK2GvS6jHfWhlBmkadrG",
* "inputs": [
* {
* "id": "position",
* "type": "point",
* "name": "Position",
* "description": "The position to pass to the service.",
* "formatter": "longitudeCommaLatitude"
* },
* {
* "id": "someOtherParameter",
* "type": "string",
* "name": "Some Other Parameter",
* "description": "This is another parameter that will be passed to the service."
* }
* ]
* }
* ```
*
* For this `CatalogFunction` TerriaJS will present a user interface with two elements: a position on the map
* and a string. When invoked, TerriaJS will GET a URL like:
* `https://putsreq.com/PK2GvS6jHfWhlBmkadrG?position=151.0%2C-33.0&someOtherParameter=some%20text`
*
* The service is expected to return JSON using the `application/json` content type, and have a body
* with any of the following:
*
* * A single catalog member
*
* For example:
*
* ```
* {
* "type": "csv",
* "data": "POSTCODE,value\n2000,1"
* }
* ```
*
* The catalog member will be added to the catalog inside a catalog group directly below this
* `CatalogFunction`. Catalog items will also be added to the workbench unless `isEnabled` is
* explicitly set to false.
*
* If the catalog item does not have a name, as in the above example, its name will be the name of
* this `CatalogFunction` followed by the date and time it was invoked in ISO8601 format. If the catalog item
* does not have a description, it will be given a description explaining that this is the result of executing
* a service and will include the input parameters sent to the service.
*
* * An array of catalog members
*
* An array of catalog members as described above.
*
* For example:
*
* ```
* [
* {
* "type": "csv",
* "data": "POSTCODE,value\n2000,1"
* },
* {
* "name": "My Result WMS Layer",
* "type": "wms",
* "url": "http://ereeftds.bom.gov.au/ereefs/tds/wms/ereefs/mwq_gridAgg_P1A",
* "layers": "Chl_MIM_mean"
* }
* ]
* ```
*
* * A catalog file
*
* For example:
*
* ```
* {
* "catalog": [
* {
* "name": "National Datasets",
* "type": "group",
* "items": [
* {
* "name": "My Result WMS Layer",
* "type": "wms",
* "url": "http://ereeftds.bom.gov.au/ereefs/tds/wms/ereefs/mwq_gridAgg_P1A",
* "layers": "Chl_MIM_mean",
* "isEnabled": true
* }
* ]
* }
* ],
* "initialCamera": {
* "west": 141.0,
* "south": -26.0,
* "east": 157.0,
* "north": -9.0
* }
* }
* ```
*
* Please note that in this case, catalog items are _not_ automatically enabled or named.
* The `name` property is required. If `isEnabled` is not set to `true`, the catalog item
* will not appear on the workbench.
*
* * Share data
*
* Similar to the above except that it allows multiple init sources (catalog files) and has a
* version property for backward compatibility. For example:
*
* ```
* {
* "version": "0.0.05",
* "initSources": [
* {
* "catalog": [
* {
* "name": "National Datasets",
* "type": "group",
* "items": [
* {
* "name": "My Result WMS Layer",
* "type": "wms",
* "url": "http://ereeftds.bom.gov.au/ereefs/tds/wms/ereefs/mwq_gridAgg_P1A",
* "layers": "Chl_MIM_mean",
* "isEnabled": true
* }
* ]
* }
* ],
* },
* {
* "initialCamera": {
* "west": 141.0,
* "south": -26.0,
* "east": 157.0,
* "north": -9.0
* }
* }
* ]
* }
* ```
*
* @alias TerriaJsonCatalogFunction
* @constructor
* @extends CatalogFunction
*
* @param {Terria} terria The Terria instance.
*/
function TerriaJsonCatalogFunction(terria) {
CatalogFunction.call(this, terria);
/**
* Gets or sets the URL of the REST server. This property is observable.
* @type {String}
*/
this.url = undefined;
/*
* Gets or sets the part of a HTTP 202 response to poll again for content.
* A HTTP 202 response is returned as an object with
* {
* status: 202
* headers: responseHeaders
* respone: responseBody
* }
* where
* responseHeaders is an object with key-value pairs of all HTTP response
* headers
* responseBody is the unaltered XMLHttpRequest.response
* If you were to supply a 202 response with the url as a location header,
* acceptedUrl can be specified as "headers.location" to poll it again
* after acceptedDelay
* If you were to supply a 202 response with the url as a property of a
* JSON object in the response body, for example
* {
* prop1: {
* prop2: "something",
* prop3: url
* }
* }
* acceptedUrl can be specified as response.prop1.prop3
* If you were to supply a 202 response with the url as plain text in the
* response body, acceptedUrl can be specified as response
* If you were to supply a 202 response with the url as a property of some
* other mime type/format, eg. XML, YAML
* This is open source software, add the code yourself.
* Leave undefined for new functionality to be off by default
* @type {String}
*/
this.acceptedUrl = undefined;
/*
* Gets or sets the time in milliseconds to wait before polling a
* HTTP 202 response acceptedUrl
* default 10 seconds
* is unused if acceptUrl is not defined
* @type {Number}
*/
this.acceptedDelay = 10000;
/**
* Gets or sets the input parameters to the service.
* @type {FunctionParameter[]}
*/
this.inputs = [];
knockout.track(this, ["url", "inputs"]);
}
inherit(CatalogFunction, TerriaJsonCatalogFunction);
Object.defineProperties(TerriaJsonCatalogFunction.prototype, {
/**
* Gets the type of data item represented by this instance.
* @memberOf TerriaJSONCatalogFunction.prototype
* @type {String}
*/
type: {
get: function() {
return "terria-json";
}
},
/**acceptedUrl
* Gets a human-readable name for this type of data source, 'Terria JSON Catalog Function'.
* @memberOf TerriaJSONCatalogFunction.prototype
* @type {String}
*/
typeName: {
get: function() {
return i18next.t("models.terriaJSONcatalog.name");
}
},
/**
* Gets the set of functions used to update individual properties in {@link CatalogMember#updateFromJson}.
* When a property name in the returned object literal matches the name of a property on this instance, the value
* will be called as a function and passed a reference to this instance, a reference to the source JSON object
* literal, and the name of the property.
* @memberOf WebMapServiceCatalogItem.prototype
* @type {Object}
*/
updaters: {
get: function() {
return TerriaJsonCatalogFunction.defaultUpdaters;
}
},
/**
* Gets the set of functions used to serialize individual properties in {@link CatalogMember#serializeToJson}.
* When a property name on the model matches the name of a property in the serializers object literal,
* the value will be called as a function and passed a reference to the model, a reference to the destination
* JSON object literal, and the name of the property.
* @memberOf WebMapServiceCatalogItem.prototype
* @type {Object}
*/
serializers: {
get: function() {
return TerriaJsonCatalogFunction.defaultSerializers;
}
},
/**
* Gets the set of names of the properties to be serialized for this object when {@link CatalogMember#serializeToJson} is called
* for a share link.
* @memberOf WebMapServiceCatalogItem.prototype
* @type {String[]}
*/
propertiesForSharing: {
get: function() {
return TerriaJsonCatalogFunction.defaultPropertiesForSharing;
}
},
/**
* Gets the parameters used to {@link CatalogFunction#invoke} to this process.
* @memberOf CatalogFunction
* @type {CatalogFunctionParameters[]}
*/
parameters: {
get: function() {
return this.inputs;
}
}
});
TerriaJsonCatalogFunction.defaultUpdaters = clone(
CatalogFunction.defaultUpdaters
);
TerriaJsonCatalogFunction.defaultUpdaters.inputs = function(
catalogFunction,
json,
propertyName,
options
) {
if (!json.inputs) {
return;
}
catalogFunction.inputs = json.inputs.map(parameterJson => {
const parameter = createParameterFromType(parameterJson.type, {
terria: catalogFunction.terria,
catalogFunction: catalogFunction,
id: parameterJson.id
});
parameter.updateFromJson(parameterJson);
return parameter;
});
};
Object.freeze(TerriaJsonCatalogFunction.defaultUpdaters);
TerriaJsonCatalogFunction.defaultSerializers = clone(
CatalogFunction.defaultSerializers
);
TerriaJsonCatalogFunction.defaultSerializers.inputs = function(
catalogFunction,
json,
propertyName,
options
) {
if (!catalogFunction.inputs) {
return;
}
json[propertyName] = catalogFunction.inputs.map(parameter =>
parameter.serializeToJson()
);
};
Object.freeze(TerriaJsonCatalogFunction.defaultSerializers);
TerriaJsonCatalogFunction.defaultPropertiesForSharing = clone(
CatalogFunction.defaultPropertiesForSharing
);
TerriaJsonCatalogFunction.prototype._load = function() {};
/**
* Invoke the REST function with the provided parameterValues.
* @return {Promise}
*/
TerriaJsonCatalogFunction.prototype.invoke = function() {
var now = new Date();
var timestamp = sprintf(
"%04d-%02d-%02dT%02d:%02d:%02d",
now.getFullYear(),
now.getMonth() + 1,
now.getDate(),
now.getHours(),
now.getMinutes(),
now.getSeconds()
);
var asyncResult = new ResultPendingCatalogItem(this.terria);
asyncResult.name = this.name + " " + timestamp;
asyncResult.description =
i18next.t("models.terriaJSONcatalog.asyncResultDescription", {
name: this.name,
timestamp: timestamp
}) +
"\n\n" +
'<table class="cesium-infoBox-defaultTable">' +
(this.parameters || []).reduce(function(previousValue, parameter) {
return (
previousValue +
"<tr>" +
'<td style="vertical-align: middle">' +
parameter.name +
"</td>" +
"<td>" +
parameter.formatValueAsString(parameter.value) +
"</td>" +
"</tr>"
);
}, "") +
"</table>";
const queryParameters = {};
this.parameters.forEach(parameter => {
if (!defined(parameter.value) || parameter.value === "") {
return;
}
// determine if parameter is a group of parameters and
// if parameter has groupIsTransparent in its AvailableFormatters
// and parameter is configured to use groupIsTransparent
if (
parameter.type.endsWith("-group") &&
"groupIsTransparent" in parameter.availableFormatters &&
(parameter.formatter === "groupIsTransparent" ||
(parameter.formatter === "default" &&
parameter.availableFormatters["default"] ===
parameter.availableFormatters["groupIsTransparent"]))
) {
parameter.formatForService().forEach(function(param) {
queryParameters[param.id] = param.value;
});
} else {
queryParameters[parameter.id] = parameter.formatForService();
}
});
const uri = new URI(this.url).addQuery(queryParameters);
const proxiedUrl = proxyCatalogItemUrl(this, uri.toString(), "1d");
const promise = Resource.fetchXHR({
url: proxiedUrl,
responseType: "text",
headers: {
Accept: "application/json,*/*;q=0.01"
},
returnType: "XHRJSONHEADERS"
})
.then(xhr => this._handleHttp202(xhr))
.then(json => {
asyncResult.isEnabled = false;
// JSON response may be:
// 1. A single catalog member; it will be added to the workbench.
// 2. An array of catalog members; they'll all be added to the workbench.
// 3. A TerriaJS init source (catalog file); it will be merged into the catalogue.
// 4. A TerriaJS "share data" object, which may contain multiple init sources.
if (json.version && json.initSources) {
// Case #4
return this.terria.updateFromStartData(json);
} else if (Array.isArray(json.catalog)) {
// Case #3
return this.terria.addInitSource(json);
}
// Case #1 or #2
const items = Array.isArray(json) ? json : [json];
items.forEach(function(item) {
// Make sure it shows up on the workbench, unless explicitly told not to.
if (!defined(item.isEnabled)) {
item.isEnabled = true;
}
item.name = item.name || asyncResult.name;
item.description = item.description || asyncResult.description;
});
// Create a group in the catalog to hold the results
const resultsGroupId = this.uniqueId + "-results";
let resultsGroup = this.terria.catalog.shareKeyIndex[resultsGroupId];
if (!resultsGroup) {
const parent =
this.parent && this.parent.items
? this.parent
: this.terria.catalog.group;
let index = parent.items.indexOf(this);
if (index >= 0) {
++index;
} else {
index = parent.items.length;
}
resultsGroup = new CatalogGroup(this.terria);
resultsGroup.id = resultsGroupId;
resultsGroup.name = this.name + " Results";
parent.items.splice(index, 0, resultsGroup);
}
return CatalogGroup.updateItems(
items,
{
isUserSupplied: true
},
resultsGroup
);
});
asyncResult.loadPromise = promise;
asyncResult.isEnabled = true;
return promise;
};
// case insenitive hasOwnProperty variants
// headers case insensitive, javascript case sensitive
// check for headers all lowercase, all uppercase,
// first letter uppercase, or if as specified
function hasPropertyUpperCase(obj, prop) {
return (
Object.keys(obj).filter(function(key) {
return key === prop.toUpperCase();
}).length > 0
);
}
function hasPropertyLowerCase(obj, prop) {
return (
Object.keys(obj).filter(function(key) {
return key === prop.toLowerCase();
}).length > 0
);
}
function hasPropertyUpperCaseFirstLetter(obj, prop) {
return (
Object.keys(obj).filter(function(key) {
return (
key ===
prop.toLowerCase().replace(prop.charAt(0), prop.charAt(0).toUpperCase())
);
}).length > 0
);
}
// private method split into function to make it recursive
TerriaJsonCatalogFunction.prototype._handleHttp202 = function(xhr) {
// A HTTP 202 response to try and get the data from polling acceptedUrl
// after a delay of acceptedDelay || 10 seconds
// If MisconfiguredError manages to be called, then acceptedUrl should have been defined
var MisconfiguredError = function(xhr, catalogFunction) {
if (
catalogFunction.acceptedUrl === undefined ||
catalogFunction.acceptedUrl === ""
) {
return new TerriaError({
sender: catalogFunction,
title: i18next.t("models.terriaJSONcatalog.misconfiguredErrorTitle"),
message: i18next.t("models.terriaJSONcatalog.misconfiguredErrorMessage")
});
}
return new TerriaError({
sender: catalogFunction,
title: i18next.t("models.terriaJSONcatalog.misconfiguredErrorTitle"),
message: i18next.t(
"models.terriaJSONcatalog.misconfiguredError2Message",
{ url: catalogFunction.acceptedUrl, xhr: JSON.stringify(xhr) }
)
});
};
// acceptedUrl = undefined as off by default
if (
this.acceptedUrl !== undefined &&
xhr.status !== undefined &&
xhr.status === 202
) {
if (typeof this.acceptedUrl !== "string") {
throw MisconfiguredError(xhr, this);
}
// JSON-ify the body here, used Resorce.fetch, so body must be text
if (
xhr.response !== undefined &&
typeof xhr.response === "string" &&
xhr.response !== ""
) {
try {
xhr.response = JSON.parse(xhr.response);
} catch (e) {
// add support for 202 bodies that are not json here if desired
// this is not an error condition as xhr.response might be legitimately
// empty and the desired information could be in the headers.
// JSON.parse(null), JSON.parse(undefined) or JSON.parse("")
// cause syntax errors
}
}
var newResourceOpts = {
url: "",
responseType: "text",
headers: {
Accept: "application/json,*/*;q=0.01"
},
returnType: "XHRJSONHEADERS"
};
// ability to handle Accepted URL locations like first.second
// or Headers.location or Body.prop1.prop2.whatever.etc
// header fields having lower or upper case first letters seems to be
// operating systems dependant
// Windows/Firefox headers have first letters uppercase
// (Linux/Andriod)/(Firefox/Chrome) headers have first letters lowercase
// headers are case insensitive.
var iteratorXhr = xhr;
var referenceIterator = this.acceptedUrl.split(".");
var iteratorCurrent;
for (iteratorCurrent of referenceIterator) {
var iteratorCaseMatched = iteratorCurrent;
if (hasPropertyUpperCase(iteratorXhr, iteratorCurrent)) {
iteratorCaseMatched = iteratorCaseMatched.toUpperCase();
} else if (hasPropertyLowerCase(iteratorXhr, iteratorCurrent)) {
iteratorCaseMatched = iteratorCaseMatched.toLowerCase();
} else if (
hasPropertyUpperCaseFirstLetter(iteratorXhr, iteratorCurrent)
) {
iteratorCaseMatched = iteratorCaseMatched
.toLowerCase()
.replace(
iteratorCaseMatched.charAt(0),
iteratorCaseMatched.charAt(0).toUpperCase()
);
}
//if attemtps to match case unsuccessful, declare as misconigured
if (!iteratorXhr.hasOwnProperty(iteratorCaseMatched)) {
throw MisconfiguredError(xhr, this);
}
iteratorXhr = iteratorXhr[iteratorCaseMatched];
}
newResourceOpts.url = proxyCatalogItemUrl(this, iteratorXhr, "1d");
// waitms is a function because i think it is a little easier to read
var waitms = function(inputs) {
return new Promise(resolve => {
setTimeout(() => {
resolve(inputs.callback(inputs.terria));
}, inputs.delay);
});
};
// prefer not to copy terria object as this will recursively consume
// stack memory, unless terria is defined as a reference of an object
// where the reference is passed by value
var that = new Proxy(this, {});
// wait for acceptedDelay before polling 202 link specified by
// 202 reponse and acceptedUrl
return waitms({
delay: this.acceptedDelay,
terria: that,
callback: function(terria) {
return Resource.fetchXHR(newResourceOpts).then(xhr =>
terria._handleHttp202(xhr)
);
}
});
} else {
// shold now have the promised JSON
// goes straight here if JSON was returned first with status 200
// or if off by acceptedUrl = undefined default
if (xhr.status !== undefined && xhr.status >= 200 && xhr.status < 300) {
if (xhr.status === 204) {
return {};
}
try {
return JSON.parse(xhr.response);
} catch (err) {
throw new TerriaError({
sender: this,
title: i18next.t(
"models.terriaJSONcatalog.serviceResponseErrorTitle"
),
message: i18next.t(
"models.terriaJSONcatalog.serviceResponseErrorMessage",
{ xhr: JSON.stringify(xhr) }
)
});
}
} else {
throw new TerriaError({
sender: this,
title: i18next.t("models.terriaJSONcatalog.requestFailedTitle"),
message: i18next.t("models.terriaJSONcatalog.requestFailedMessage", {
status: xhr.status
})
});
}
}
};
module.exports = TerriaJsonCatalogFunction;