Source: openeo.js

if (typeof axios === 'undefined') {
	/* jshint ignore:start */
	var axios = require('axios');
	/* jshint ignore:end */
}
if (typeof axios === 'undefined') {
	/* jshint ignore:start */
	var oidcClient = require('oidc-client');
	var { UserManager } = oidcClient;
	/* jshint ignore:end */
}

/**
 * Flag that indicated whether we are nunning in a NodeJS environment (`true`) or not (`false`).
 * 
 * @var {boolean}
 */
let isNode = false;
try {
	isNode = (typeof window === 'undefined' && Object.prototype.toString.call(global.process) === '[object process]');
} catch(e) {}

/**
 * Main class to start with openEO. Allows to connect to a server.
 * 
 * @class
 * @hideconstructor
 */
class OpenEO {

	/**
	 * Connect to a back-end with version discovery (recommended).
	 * 
	 * Includes version discovery (request to `GET /well-known/openeo`) and connects to the most suitable version compatible to this JS client version.
	 * Requests the capabilities and authenticates where required.
	 * 
	 * @async
	 * @param {string} url - The server URL to connect to.
	 * @param {string} [authType=null] - Authentication type, either `basic` for HTTP Basic, `oidc` for OpenID Connect (Browser only) or `null` to disable authentication.
	 * @param {object} [authOptions={}] - Object with authentication options.
	 * @param {string} [authOptions.username] - HTTP Basic only: Username
	 * @param {string} [authOptions.password] - HTTP Basic only: Password
	 * @param {string} [authOptions.clientId] - OpenID Connect only: Your client application's identifier as registered with the OIDC provider
	 * @param {string} [authOptions.redirectUri] - OpenID Connect only: The redirect URI of your client application to receive a response from the OIDC provider.
	 * @returns {Connection}
	 * @throws {Error}
	 * @static
	 */
	static async connect(url, authType = null, authOptions = {}) {
		let wellKnownUrl = Util.normalizeUrl(url, '/.well-known/openeo');
		let response;
		try {
			response = await axios.get(wellKnownUrl);

			if (!Util.isObject(response.data) || !Array.isArray(response.data.versions)) {
				throw new Error("Well-Known Document doesn't list any version.");
			}
	
			let compatibility = Util.mostCompatible(response.data.versions);
			if (compatibility.length > 0) {
				url = compatibility[0].url;
			}
			else {
				throw new Error("Server doesn't support API version 0.4.x.");
			}
		} catch(error) {
			/** @todo We should replace the fallback in a 1.0 or so. */
			if (error.response && [403,404,405,501].includes(error.response.status)) {
				console.warn("DEPRECATED: Can't read well-known document, connecting directly to the specified URL as fallback mechanism.");
			}
			else {
				throw error;
			}
		}

		return await OpenEO.connectDirect(url, authType, authOptions);
	}

	/**
	 * Connects directly to a back-end instance, without version discovery (NOT recommended).
	 * 
	 * Doesn't do version discovery, therefore a URL of a versioned API must be specified. Requests the capabilities and authenticates where required.
	 * 
	 * @async
	 * @param {string} url - The server URL to connect to.
	 * @param {string} [authType=null] - Authentication type, either `basic` for HTTP Basic, `oidc` for OpenID Connect (Browser only) or `null` to disable authentication.
	 * @param {object} [authOptions={}] - Object with authentication options.
	 * @param {string} [authOptions.username] - HTTP Basic only: Username
	 * @param {string} [authOptions.password] - HTTP Basic only: Password
	 * @param {string} [authOptions.clientId] - OpenID Connect only: Your client application's identifier as registered with the OIDC provider
	 * @param {string} [authOptions.redirectUri] - OpenID Connect only: The redirect URI of your client application to receive a response from the OIDC provider.
	 * @returns {Connection}
	 * @throws {Error}
	 * @static
	 */
	static async connectDirect(versionedUrl, authType = null, authOptions = {}) {
		let connection = new Connection(versionedUrl);

		// Check whether back-end is accessible and supports a compatible version.
		let capabilities = await connection.init();
		if (!capabilities.apiVersion().startsWith("0.4.")) {
			throw new Error("Server instance doesn't support API version 0.4.x.");
		}

		if(authType !== null) {
			switch(authType) {
				case 'basic':
					await connection.authenticateBasic(authOptions.username, authOptions.password);
					break;
				case 'oidc':
					await connection.authenticateOIDC(authOptions.clientId, authOptions.redirectUri);
					break;
				default:
					throw new Error("Unknown authentication type.");
			}
		}

		return connection;
	}

	/**
	 * Returns the version number of the client.
	 * 
	 * Not to confuse with the API version(s) supported by the client.
	 * 
	 * @returns {string} Version number (according to SemVer).
	 */
	static clientVersion() {
		return "0.4.0";
	}

}

/**
 * A connection to a back-end.
 * 
 * @class
 */
class Connection {

	/**
	 * Creates a new Connection.
	 * 
	 * @param {string} baseUrl - URL to the back-end
	 * @constructor
	 */
	constructor(baseUrl) {
		this.baseUrl = Util.normalizeUrl(baseUrl);
		this.userId = null;
		this.accessToken = null;
		this.oidc = null;
		this.oidcUser = null;
		this.capabilitiesObject = null;
		this.subscriptionsObject = new Subscriptions(this);
	}

	/**
	 * Initializes the connection by requesting the capabilities.
	 * 
	 * @async
	 * @returns {Capabilities} Capabilities
	 */
	async init() {
		let response = await this._get('/');
		this.capabilitiesObject = new Capabilities(response.data);
		return this.capabilitiesObject;
	}

	/**
	 * Returns the URL of the back-end currently connected to.
	 * 
	 * @returns {string} The URL or the back-end.
	 */
	getBaseUrl() {
		return this.baseUrl;
	}

	/**
	 * Returns the identifier of the user that is currently authenticated at the back-end.
	 * 
	 * @returns {string} ID of the authenticated user.
	 */
	getUserId() {
		return this.userId;
	}

	/**
	 * Returns the capabilities of the back-end.
	 * 
	 * @returns {Capabilities} Capabilities
	 */
	capabilities() {
		return this.capabilitiesObject;
	}

	/**
	 * List the supported output file formats.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async listFileTypes() {
		let response = await this._get('/output_formats');
		return response.data;
	}

	/**
	 * List the supported secondary service types.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async listServiceTypes() {
		let response = await this._get('/service_types');
		return response.data;
	}

	/**
	 * List the supported UDF runtimes.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async listUdfRuntimes() {
		let response = await this._get('/udf_runtimes');
		return response.data;
	}

	/**
	 * List all collections available on the back-end.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async listCollections() {
		let response = await this._get('/collections');
		return response.data;
	}

	/**
	 * Get further information about a single collection.
	 * 
	 * @async
	 * @param {string} collectionId - Collection ID to request further metadata for.
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async describeCollection(collectionId) {
		let response = await this._get('/collections/' + collectionId);
		return response.data;
	}

	/**
	 * List all processes available on the back-end.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async listProcesses() {
		let response = await this._get('/processes');
		return response.data;
	}

	/**
	 * Authenticate with OpenID Connect (OIDC).
	 * 
	 * Supported only in Browser environments.
	 * 
	 * Not required to be called explicitly if specified in `OpenEO.connect`.
	 * 
	 * @param {object} options - Options for OIDC authentication.
	 * @returns {object} Response of `desribeAccount()`.
	 * @throws {Error}
	 * @todo Fully implement OpenID Connect authentication {@link https://github.com/Open-EO/openeo-js-client/issues/11}
	 */
	async authenticateOIDC(clientId, redirectUri) {
		if (isNode || typeof window === 'undefined') {
			throw "OpenID Connect authentication is only supported in a browser environment";
		}
		var response = await this._send({
			method: 'get',
			url: '/credentials/oidc',
			maxRedirects: 0 // Disallow redirects
		});
		var responseUrl = isNode ? response.request.res.responseUrl : response.request.responseURL;
		if (typeof responseUrl !== 'string') {
			throw "No URL available for OpenID Connect Discovery";
		}
		this.oidc = new UserManager({
			authority: responseUrl.replace('/.well-known/openid-configuration', ''),
			client_id: clientId,
			redirect_uri: redirectUri
		});
		this.oidcUser = await this.oidc.signinPopup();
		this.accessToken = this.oidcUser.access_token;
		// Either decode id_token or request describeAccount
		if (this.capabilities.hasFeature('describeAccount')) {
			var me = await this.describeAccount();
			this.userId = me.userId;
			return me;
		}
		else {
			// ToDo: Decode id_token?!
			throw "Could not load user information.";
		}
	}

	/**
	 * Authenticate with HTTP Basic.
	 * 
	 * Not required to be called explicitly if specified in `OpenEO.connect`.
	 * 
	 * @async
	 * @param {object} options - Options for Basic authentication.
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async authenticateBasic(username, password) {
		let response = await this._send({
			method: 'get',
			responseType: 'json',
			url: '/credentials/basic',
			headers: {'Authorization': 'Basic ' + Util.base64encode(username + ':' + password)}
		});
		if (!response.data.user_id) {
			throw new Error("No user_id returned.");
		}
		if (!response.data.access_token) {
			throw new Error("No access_token returned.");
		}
		this.userId = response.data.user_id;
		this.accessToken = response.data.access_token;
		return response.data;
	}

	async logout() {
		if (this.oidc !== null) {
			await this.oidc.signoutPopup();
			this.oidc = null;
			this.oidcUser = null;
		}
		this.userId = null;
		this.accessToken = null;
	}

	/**
	 * Get information about the authenticated user.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async describeAccount() {
		let response = await this._get('/me');
		return response.data;
	}

	/**
	 * Lists all files from the user workspace. 
	 * 
	 * @async
	 * @param {string} [userId=null] - User ID, defaults to authenticated user.
	 * @returns {File[]} A list of files.
	 * @throws {Error}
	 */
	async listFiles(userId = null) {
		userId = this._resolveUserId(userId);
		let response = await this._get('/files/' + userId);
		return response.data.files.map(
			f => new File(this, userId, f.path).setAll(f)
		);
	}

	/**
	 * Opens a (existing or non-existing) file without reading any information or creating a new file at the back-end. 
	 * 
	 * @param {string} path - Path to the file, relative to the user workspace.
	 * @param {string} [userId=null] - User ID, defaults to authenticated user.
	 * @returns {File} A file.
	 * @throws {Error}
	 */
	openFile(path, userId = null) {
		return new File(this, this._resolveUserId(userId), path);
	}

	/**
	 * Validates a process graph at the back-end.
	 * 
	 * @async
	 * @param {object} processGraph - Process graph to validate.
	 * @retrurns {Object[]} errors - A list of API compatible error objects. A valid process graph returns an empty list.
	 * @throws {Error}
	 */
	async validateProcessGraph(processGraph) {
		let response = await this._post('/validation', {process_graph: processGraph});
		if (Array.isArray(response.data.errors)) {
			return response.data.errors;
		}
		else {
			throw new Error("Invalid validation response received.");
		}
	}

	/**
	 * Lists all process graphs of the authenticated user.
	 * 
	 * @async
	 * @returns {ProcessGraph[]} A list of stored process graphs.
	 * @throws {Error}
	 */
	async listProcessGraphs() {
		let response = await this._get('/process_graphs');
		return response.data.process_graphs.map(
			pg => new ProcessGraph(this, pg.id).setAll(pg)
		);
	}

	/**
	 * Creates a new stored process graph at the back-end.
	 * 
	 * @async
	 * @param {object} processGraph - A process graph (JSON).
	 * @param {string} [title=null] - A title for the stored process graph.
	 * @param {string} [description=null] - A description for the stored process graph.
	 * @returns {ProcessGraph} The new stored process graph.
	 * @throws {Error}
	 */
	async createProcessGraph(processGraph, title = null, description = null) {
		let requestBody = {title: title, description: description, process_graph: processGraph};
		let response = await this._post('/process_graphs', requestBody);
		let obj = new ProcessGraph(this, response.headers['openeo-identifier']).setAll(requestBody);
		if (await this.capabilitiesObject.hasFeature('describeProcessGraph')) {
			return obj.describeProcessGraph();
		}
		else {
			return obj;
		}
	}

	/**
	 * Get all information about a stored process graph.
	 * 
	 * @async
	 * @param {string} id - Process graph ID. 
	 * @returns {ProcessGraph} The stored process graph.
	 * @throws {Error}
	 */
	async getProcessGraphById(id) {
		let pg = new ProcessGraph(this, id);
		return await pg.describeProcessGraph();
	}

	/**
	 * Executes a process graph synchronously and returns the result as the response.
	 * 
	 * Please note that requests can take a very long time of several minutes or even hours.
	 * 
	 * @async
	 * @param {object} processGraph - A process graph (JSON).
	 * @param {string} [plan=null] - The billing plan to use for this computation.
	 * @param {number} [budget=null] - The maximum budget allowed to spend for this computation.
	 * @returns {Stream|Blob} - Returns the data as `Stream` in NodeJS environments or as `Blob` in browsers (see `isNode`).
	 */
	async computeResult(processGraph, plan = null, budget = null) {
		let requestBody = {
			process_graph: processGraph,
			plan: plan,
			budget: budget
		};
		let response = await this._post('/result', requestBody, 'stream');
		return response.data;
	}

	/**
	 * Lists all batch jobs of the authenticated user.
	 * 
	 * @async
	 * @returns {Job[]} A list of jobs.
	 * @throws {Error}
	 */
	async listJobs() {
		let response = await this._get('/jobs');
		return response.data.jobs.map(
			j => new Job(this, j.id).setAll(j)
		);
	}

	/**
	 * Creates a new batch job at the back-end.
	 * 
	 * @async
	 * @param {object} processGraph - A process graph (JSON).
	 * @param {string} [title=null] - A title for the batch job.
	 * @param {string} [description=null] - A description for the batch job.
	 * @param {string} [plan=null] - The billing plan to use for this batch job.
	 * @param {number} [budget=null] - The maximum budget allowed to spend for this batch job.
	 * @param {object} [additional={}] - Proprietary parameters to pass for the batch job.
	 * @returns {Job} The stored batch job.
	 * @throws {Error}
	 */
	async createJob(processGraph, title = null, description = null, plan = null, budget = null, additional = {}) {
		let requestBody = Object.assign({}, additional, {
			title: title,
			description: description,
			process_graph: processGraph,
			plan: plan,
			budget: budget
		});
		let response = await this._post('/jobs', requestBody);
		let job = new Job(this, response.headers['openeo-identifier']).setAll(requestBody);
		if (this.capabilitiesObject.hasFeature('describeJob')) {
			return await job.describeJob();
		}
		else {
			return job;
		}
	}

	/**
	 * Get all information about a batch job.
	 * 
	 * @async
	 * @param {string} id - Batch Job ID. 
	 * @returns {Job} The batch job.
	 * @throws {Error}
	 */
	async getJobById(id) {
		let job = new Job(this, id);
		return await job.describeJob();
	}

	/**
	 * Lists all secondary web services of the authenticated user.
	 * 
	 * @async
	 * @returns {Job[]} A list of services.
	 * @throws {Error}
	 */
	async listServices() {
		let response = await this._get('/services');
		return response.data.services.map(
			s => new Service(this, s.id).setAll(s)
		);
	}

	/**
	 * Creates a new secondary web service at the back-end. 
	 * 
	 * @async
	 * @param {object} processGraph - A process graph (JSON).
	 * @param {string} type - The type of service to be created (see `Connection.listServiceTypes()`).
	 * @param {string} [title=null] - A title for the service.
	 * @param {string} [description=null] - A description for the service.
	 * @param {boolean} [enabled=true] - Enable the service (`true`, default) or not (`false`).
	 * @param {object} [parameters={}] - Parameters to pass to the service.
	 * @param {string} [plan=null] - The billing plan to use for this service.
	 * @param {number} [budget=null] - The maximum budget allowed to spend for this service.
	 * @returns {Service} The stored service.
	 * @throws {Error}
	 */
	async createService(processGraph, type, title = null, description = null, enabled = true, parameters = {}, plan = null, budget = null) {
		let requestBody = {
			title: title,
			description: description,
			process_graph: processGraph,
			type: type,
			enabled: enabled,
			parameters: parameters,
			plan: plan,
			budget: budget
		};
		let response = await this._post('/services', requestBody);
		let service = new Service(this, response.headers['openeo-identifier']).setAll(requestBody);
		if (this.capabilitiesObject.hasFeature('describeService')) {
			return service.describeService();
		}
		else {
			return service;
		}
	}

	/**
	 * Get all information about a secondary web service.
	 * 
	 * @async
	 * @param {string} id - Service ID. 
	 * @returns {Job} The service.
	 * @throws {Error}
	 */
	async getServiceById(id) {
		let service = new Service(this, id);
		return await service.describeService();
	}

	async _get(path, query, responseType) {
		return await this._send({
			method: 'get',
			responseType: responseType,
			url: path,
			// Timeout for capabilities requests as they are used for a quick first discovery to check whether the server is a openEO back-end.
			// Without timeout connecting with a wrong server url may take forever.
			timeout: path === '/' ? 3000 : 0,
			params: query
		});
	}

	async _post(path, body, responseType) {
		return await this._send({
			method: 'post',
			responseType: responseType,
			url: path,
			data: body
		});
	}

	async _patch(path, body) {
		return await this._send({
			method: 'patch',
			url: path,
			data: body
		});
	}

	async _delete(path) {
		return await this._send({
			method: 'delete',
			url: path
		});
	}

	/**
	 * Downloads data from a URL.
	 * 
	 * May include authorization details where required.
	 * 
	 * @param {string} url - An absolute or relative URL to download data from.
	 * @param {boolean} authorize - Send authorization details (`true`) or not (`false`).
	 * @returns {Stream|Blob} - Returns the data as `Stream` in NodeJS environments or as `Blob` in browsers (see `isNode`).
	 */
	async download(url, authorize) {
		return await this._send({
			method: 'get',
			responseType: 'stream',
			url: url,
			withCredentials: authorize
		});
	}

	async _send(options) {
		options.baseURL = this.baseUrl;
		if (this.isLoggedIn() && (typeof options.withCredentials === 'undefined' || options.withCredentials === true)) {
			options.withCredentials = true;
			if (!options.headers) {
				options.headers = {};
			}
			options.headers.Authorization = 'Bearer ' + this.accessToken;
		}
		if (options.responseType === 'stream' && !isNode) {
			options.responseType = 'blob';
		}
		if (!options.responseType) {
			options.responseType = 'json';
		}

		try {
			return await axios(options);
		} catch(error) {
			if (Util.isObject(error.response) && Util.isObject(error.response.data) && ((typeof error.response.data.type === 'string' && error.response.data.type.indexOf('/json') !== -1) || (Util.isObject(error.response.data.headers) && typeof error.response.data.headers['content-type'] === 'string' && error.response.data.headers['content-type'].indexOf('/json') !== -1))) {
				// JSON error responses are Blobs and streams if responseType is set as such, so convert to JSON if required.
				// See: https://github.com/axios/axios/issues/815
				switch(options.responseType) {
					case 'blob':
						return new Promise((_, reject) => {
							let fileReader = new FileReader();
							fileReader.onerror = event => {
								fileReader.abort();
								reject(event.target.error);
							};
							fileReader.onload = () => reject(JSON.parse(fileReader.result));
							fileReader.readAsText(error.response.data);
						});
					case 'stream':
						return new Promise((_, reject) => {
							let chunks = [];
							error.response.data.on("data", chunk => chunks.push(chunk));
							error.response.data.on("error", error => reject(error));
							error.response.data.on("end", () => reject(JSON.parse(Buffer.concat(chunks).toString())));
						});
				}
			}
			// Re-throw error if it was not handled yet.
			throw error;
		}
	}

	_resolveUserId(userId = null) {
		if(userId === null) {
			if(this.userId === null) {
				throw new Error("Parameter 'userId' not specified and no default value available because user is not logged in.");
			}
			else {
				userId = this.userId;
			}
		}
		return userId;
	}

	/**
	 * Returns whether the user is authenticated (logged in) at the back-end or not.
	 * 
	 * @returns {boolean} `true` if authenticated, `false` if not.
	 */
	isLoggedIn() {
		return (this.accessToken !== null);
	}

	/**
	 * Subscribes to a topic.
	 * 
	 * @param {string} topic - The topic to subscribe to.
	 * @param {incomingMessageCallback} callback - A callback that is executed when a message for the topic is received.
	 * @param {object} [parameters={}] - Parameters for the subscription request, for example a job id.
	 * @throws {Error}
	 * @see Subscriptions.subscribe()
	 * @see https://open-eo.github.io/openeo-api/apireference-subscriptions/
	 * 
	 */
	subscribe(topic, callback, parameters = {}) {
		this.subscriptionsObject.subscribe(topic, parameters, callback);
	}

	/**
	 * Unsubscribes from a topic.
	 * 
	 * @param {string} topic - The topic to unsubscribe from.
	 * @param {object} [parameters={}] - Parameters that have been used to subsribe to the topic.
	 * @throws {Error}
	 * @see Subscriptions.unsubscribe()
	 * @see https://open-eo.github.io/openeo-api/apireference-subscriptions/
	 * 
	 */
	unsubscribe(topic, parameters = {}) {
		this.subscriptionsObject.unsubscribe(topic, parameters);
	}
}

/**
 * Web-Socket-based Subscriptions.
 * 
 * @class
 */
class Subscriptions {

	/**
	 * Creates a new object that handles the subscriptions.
	 * 
	 * @param {Connection} httpConnection - A Connection object representing an established connection to an openEO back-end.
	 * @constructor
	 */
	constructor(httpConnection) {
		this.httpConnection = httpConnection;
		this.socket = null;
		this.listeners = new Map();
		this.supportedTopics = [];
		this.messageQueue = [];
		this.websocketProtocol = "openeo-v0.4";
	}

	/**
	 * A callback that is executed when a message for the corresponding topic is received by the client.
	 * 
	 * @callback incomingMessageCallback
	 * @param {object} payload
	 * @param {string} payload.issued - Date and time when the message was sent, formatted as a RFC 3339 date-time. 
	 * @param {string} payload.topic - The type of the topic, e.g. `openeo.jobs.debug`
	 * @param {object} message - A message, usually an object with some properties. Depends on the topic.
	 * @see https://open-eo.github.io/openeo-api/apireference-subscriptions/
	 */

	/**
	 * Subscribes to a topic.
	 * 
	 * @param {string} topic - The topic to subscribe to.
	 * @param {incomingMessageCallback} callback - A callback that is executed when a message for the topic is received.
	 * @param {object} [parameters={}] - Parameters for the subscription request, for example a job id.
	 * @throws {Error}
	 * @see https://open-eo.github.io/openeo-api/apireference-subscriptions/
	 */
	subscribe(topic, callback, parameters = {}) {
		if (typeof callback !== 'function') {
			throw new Error("No valid callback specified.");
		}

		if(!this.listeners.has(topic)) {
			this.listeners.set(topic, new Map());
		}
		this.listeners.get(topic).set(Util.hash(parameters), callback);

		this._sendSubscription('subscribe', topic, parameters);
	}

	/**
	 * Unsubscribes from a topic.
	 * 
	 * @param {string} topic - The topic to unsubscribe from.
	 * @param {object} [parameters={}] - Parameters that have been used to subsribe to the topic.
	 * @throws {Error}
	 * @see https://open-eo.github.io/openeo-api/apireference-subscriptions/
	 */
	unsubscribe(topic, parameters = {}) {
		// get all listeners for the topic
		let topicListeners = this.listeners.get(topic);

		// remove the applicable sub-callback
		if(!(topicListeners instanceof Map)) {
			throw new Error("this.listeners must be a Map of Maps");
		}

		topicListeners.delete(Util.hash(parameters));
		// Remove entire topic from subscriptionListeners if no topic-specific listener is left
		if(topicListeners.size === 0) {
			this.listeners.delete(topic);
		}

		// now send the command to the server
		this._sendSubscription('unsubscribe', topic, parameters);

		// Close subscription socket if there is no subscription left (use .size, NOT .length!)
		if (this.socket !== null && this.listeners.size === 0) {
			console.log('Closing connection because there is no subscription left');
			this.socket.close();
		}
	}

	_createWebSocket() {
		if (this.socket === null || this.socket.readyState === this.socket.CLOSING || this.socket.readyState === this.socket.CLOSED) {
			this.messageQueue = [];
			let url = this.httpConnection.getBaseUrl().replace('http', 'ws') + '/subscription';

			if (isNode) {
				const WebSocket = require('ws');
				this.socket = new WebSocket(url, this.websocketProtocol);
			}
			else {
				this.socket = new WebSocket(url, this.websocketProtocol);
			}

			this._sendAuthorize();

			this.socket.addEventListener('open', () => this._flushQueue());

			this.socket.addEventListener('message', event => this._receiveMessage(event));

			this.socket.addEventListener('error', () => {
				this.socket = null;
			});

			this.socket.addEventListener('close', () => {
				this.socket = null;
			});
		}
		return this.socket;
	}

	_receiveMessage(event) {
		/** @todo Add error handling */
		let json = JSON.parse(event.data);
		if (json.message.topic === 'openeo.welcome') {
			this.supportedTopics = json.payload.topics;
		}
		else {
			// get listeners for topic
			let topicListeners = this.listeners.get(json.message.topic);
			let callback;
			// we should now have a Map in which to look for the correct listener
			if (topicListeners && topicListeners instanceof Map) {
				// This checks for no parameters OR for job_id, more parameter checks possible in future versions.
				/** @todo Hardcoding these checks is quite messy/dirty, we should implement a better solution. */
				callback = topicListeners.get(Util.hash({})) || topicListeners.get(Util.hash({job_id: json.payload.job_id}));
						
			}
			// if we now have a function, we can call it with the information
			if (typeof callback === 'function') {
				callback(json.payload, json.message);
			} else {
				console.log("No listener found to handle incoming message of type: " + json.message.topic);
			}
		}
	}

	_flushQueue() {
		if(this.socket.readyState === this.socket.OPEN) {
			for(let i in this.messageQueue) {
				this.socket.send(JSON.stringify(this.messageQueue[i]));
			}

			this.messageQueue = [];
		}
	}

	_sendMessage(topic, payload = null, priority = false) {
		let obj = {
			authorization: "Bearer " + this.httpConnection.accessToken,
			message: {
				topic: "openeo." + topic,
				issued: (new Date()).toISOString()
			}

		};
		if (payload !== null) {
			obj.payload = payload;
		}
		if (priority) {
			this.messageQueue.splice(0, 0, obj);
		}
		else {
			this.messageQueue.push(obj);
		}
		this._flushQueue();
	}

	_sendAuthorize() {
		this._sendMessage('authorize', null, true);
	}

	_sendSubscription(action, topic, parameters) {
		this._createWebSocket();

		if (!Util.isObject(parameters)) {
			parameters = {};
		}

		let payloadParameters = Object.assign({}, parameters, { topic: topic });

		this._sendMessage(action, {
			topics: [payloadParameters]
		});
	}

}

/**
 * Capabilities of a back-end.
 * 
 * @class
 */
class Capabilities {

	/**
	 * Creates a new Capabilities object from an API-compatible JSON response.
	 * 
	 * @param {object} data - A capabilities response compatible to the API specification.
	 * @throws {Error}
	 * @constructor
	 */
	constructor(data) {
		if(!Util.isObject(data)) {
			throw new Error("No capabilities retrieved.");
		}
		if(!data.api_version) {
			throw new Error("Invalid capabilities: No API version retrieved");
		}
		if(!Array.isArray(data.endpoints)) {
			throw new Error("Invalid capabilities: No endpoints retrieved");
		}

		this.data = data;

		// Flatten features to be compatible with the feature map.
		this.features = this.data.endpoints
			.map(e => e.methods.map(method => (method + ' ' + e.path).toLowerCase()))
			// .flat(1)   // does exactly what we want, but (as of Sept. 2018) not yet part of the standard...
			.reduce((a, b) => a.concat(b), []); // ES6-proof version of flat(1);

		this.featureMap = {
			capabilities: 'get /',
			listFileTypes: 'get /output_formats',
			listServiceTypes: 'get /service_types',
			listUdfRuntimes: 'get /udf_runtimes',
			listCollections: 'get /collections',
			describeCollection: 'get /collections/{collection_id}',
			listProcesses: 'get /processes',
			authenticateOIDC: 'get /credentials/oidc',
			authenticateBasic: 'get /credentials/basic',
			describeAccount: 'get /me',
			listFiles: 'get /files/{user_id}',
			validateProcessGraph: 'post /validation',
			createProcessGraph: 'post /process_graphs',
			listProcessGraphs: 'get /process_graphs',
			computeResult: 'post /result',
			listJobs: 'get /jobs',
			createJob: 'post /jobs',
			listServices: 'get /services',
			createService: 'post /services',
			downloadFile: 'get /files/{user_id}/{path}',
			openFile: 'put /files/{user_id}/{path}',
			uploadFile: 'put /files/{user_id}/{path}',
			deleteFile: 'delete /files/{user_id}/{path}',
			getJobById: 'get /jobs/{job_id}',
			describeJob: 'get /jobs/{job_id}',
			updateJob: 'patch /jobs/{job_id}',
			deleteJob: 'delete /jobs/{job_id}',
			estimateJob: 'get /jobs/{job_id}/estimate',
			startJob: 'post /jobs/{job_id}/results',
			stopJob: 'delete /jobs/{job_id}/results',
			listResults: 'get /jobs/{job_id}/results',
			downloadResults: 'get /jobs/{job_id}/results',
			describeProcessGraph: 'get /process_graphs/{process_graph_id}',
			getProcessGraphById: 'get /process_graphs/{process_graph_id}',
			updateProcessGraph: 'patch /process_graphs/{process_graph_id}',
			deleteProcessGraph: 'delete /process_graphs/{process_graph_id}',
			describeService: 'get /services/{service_id}',
			getServiceById: 'get /services/{service_id}',
			updateService: 'patch /services/{service_id}',
			deleteService: 'delete /services/{service_id}',
			subscribe: 'get /subscription',
			unsubscribe: 'get /subscription'
		};
	}

	/**
	 * Returns the capabilities response as a plain object.
	 * 
	 * @returns {object} - A reference to the capabilities response.
	 */
	toPlainObject() {
		return this.data;
	}

	/**
	 * Returns the openEO API version implemented by the back-end.
	 * 
	 * @returns {string} openEO API version number.
	 */
	apiVersion() {
		return this.data.api_version;
	}

	/**
	 * Returns the back-end version number.
	 * 
	 * @returns {string} openEO back-end version number.
	 */
	backendVersion() {
		return this.data.backend_version;
	}

	/**
	 * Returns the back-end title.
	 * 
	 * @returns {string} Title
	 */
	title() {
		return this.data.title || "";
	}

	/**
	 * Returns the back-end description.
	 * 
	 * @returns {string} Description
	 */
	description() {
		return this.data.description || "";
	}

	/**
	 * Lists all supported features.
	 * 
	 * @returns {string[]} An array of supported features.
	 */
	listFeatures() {
		let features = [];
		for(let feature in this.featureMap) {
			if (this.features.includes(this.featureMap[feature])) {
				features.push(feature);
			}
		}
		return features;
	}

	/**
	 * Check whether a feature is supported by the back-end.
	 * 
	 * @param {string} methodName - A feature name (corresponds to the JS client method names, see also the feature map for allowed values).
	 * @returns {boolean} `true` if the feature is supported, otherwise `false`.
	 */
	hasFeature(methodName) {
		return this.features.some(e => e === this.featureMap[methodName]);
	}

	/**
	 * Get the billing currency.
	 * 
	 * @returns {string|null} The billing currency or `null` if not available.
	 */
	currency() {
		return (this.data.billing && typeof this.data.billing.currency === 'string' ? this.data.billing.currency : null);
	}

	/**
	 * @typedef BillingPlan
	 * @type {Object}
	 * @property {string} name - Name of the billing plan.
	 * @property {string} description - A description of the billing plan, may include CommonMark syntax.
	 * @property {boolean} paid - `true` if it is a paid plan, otherwise `false`.
	 * @property {string} url - A URL pointing to a page describing the billing plan.
	 * @property {boolean} default - `true` if it is the default plan of the back-end, otherwise `false`.
	 */

	/**
	 * List all billing plans.
	 * 
	 * @returns {BillingPlan[]} Billing plans
	 */
	listPlans() {
		if (this.data.billing && Array.isArray(this.data.billing.plans)) {
			let plans = this.data.billing.plans;
			return plans.map(plan => {
				plan.default = (typeof this.data.billing.default_plan === 'string' && this.data.billing.default_plan.toLowerCase() === plan.name.toLowerCase());
				return plan;
			});
		}
		else {
			return [];
		}
	}
}


/**
 * The base class for entities such as Job, Process Graph, Service etc.
 * 
 * @class
 * @abstract
 */
class BaseEntity {

	/**
	 * Creates an instance of this object.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {object} properties 
	 * @constructor
	 */
	constructor(connection, properties = []) {
		this.connection = connection;
		this.clientNames = {};
		this.backendNames = {};
		this.extra = {};
		for(let i in properties) {
			let backend, client;
			if (Array.isArray(properties[i])) {
				backend = properties[i][0];
				client = properties[i][1];
			}
			else {
				backend = properties[i];
				client = properties[i];
			}
			this.clientNames[backend] = client;
			this.backendNames[client] = backend;
			if (typeof this[client] === 'undefined') {
				this[client] = null;
			}
		}
	}

	/**
	 * Converts the data from an API response into data suitable for our JS client models.
	 * 
	 * @param {object} metadata - JSON object originating from an API response.
	 * @returns {this} Returns the object itself.
	 */
	setAll(metadata) {
		for(let name in metadata) {
			if (typeof this.clientNames[name] === 'undefined') {
				this.extra[name] = metadata[name];
			}
			else {
				this[this.clientNames[name]] = metadata[name];
			}
		}
		return this;
	}

	/**
	 * Returns all data in the model.
	 * 
	 * @returns {object}
	 */
	getAll() {
		let obj = {};
		for(let backend in this.clientNames) {
			let client = this.clientNames[backend];
			obj[client] = this[client];
		}
		return Object.assign(obj, this.extra);
	}

	/**
	 * Get a value from the additional data that is not part of the core model, i.e. from proprietary extensions.
	 * 
	 * @param {string} name - Name of the property.
	 * @returns {*} The value, which could be of any type.
	 */
	get(name) {
		return typeof this.extra[name] !== 'undefined' ? this.extra[name] : null;
	}

	_convertToRequest(parameters) {
		let request = {};
		for(let key in parameters) {
			if (typeof this.backendNames[key] === 'undefined') {
				request[key] = parameters[key];
			}
			else {
				request[this.backendNames[key]] = parameters[key];
			}
		}
		return request;
	}

	_supports(feature) {
		return this.connection.capabilities().hasFeature(feature);
	}

}

/**
 * A File on the user workspace.
 * 
 * @class
 * @extends BaseEntity
 */
class File extends BaseEntity {

	/**
	 * Creates an object representing a file on the user workspace.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {string} userId - The user ID.
	 * @param {string} path - The path to the file, relative to the user workspace and without user ID.
	 * @constructor
	 */
	constructor(connection, userId, path) {
		super(connection, ["path", "size", "modified"]);
		this.userId = userId;
		this.path = path;
	}

	/**
	 * Downloads a file from the user workspace.
	 * 
	 * This method has different behaviour depending on the environment.
	 * If the target is set to `null`, returns a stream in a NodeJS environment or a Blob in a browser environment.
	 * If a target is specified, writes the downloaded file to the target location on the file system in a NodeJS environment.
	 * In a browser environment offers the file for downloading using the specified name (paths not supported).
	 * 
	 * @async
	 * @param {string|null} target - The target, see method description for details.
	 * @returns {Stream|Blob|void} - Return value depends on the target and environment, see method description for details.
	 * @throws {Error}
	 */
	async downloadFile(target = null) {
		let response = await this.connection.download('/files/' + this.userId + '/' + this.path, true);
		if (target === null) {
			return response.data;
		}
		else {
			return await this._saveToFile(response.data, target);
		}
	}

	async _saveToFile(data, filename) {
		if (isNode) {
			return await Util.saveToFileNode(data, filename);
		}
		else {
			/* istanbul ignore next */
			return Util.saveToFileBrowser(data, filename);
		}
	}

	_readFromFileNode(path) {
		const fs = require('fs');
		return fs.createReadStream(path);
	}


	/**
	 * A callback that is executed on upload progress updates.
	 * 
	 * @callback uploadStatusCallback
	 * @param {number} percentCompleted - The percent (0-100) completed.
	 */

	/**
	 * Uploads a file to the user workspace.
	 * If a file with the name exists, overwrites it.
	 * 
	 * This method has different behaviour depending on the environment.
	 * In a nodeJS environment the source must be a path to a file as string.
	 * In a browser environment the source must be an object from a file upload form.
	 * 
	 * @async
	 * @param {string|object} source - The source, see method description for details.
	 * @param {uploadStatusCallback|null} statusCallback - Optionally, a callback that is executed on upload progress updates.
	 * @returns {File}
	 * @throws {Error}
	 */
	async uploadFile(source, statusCallback = null) {
		if (isNode) {
			// Use a file stream for node
			source = this._readFromFileNode(source);
		}
		// else: Just use the file object from the browser

		let options = {
			method: 'put',
			url: '/files/' + this.userId + '/' + this.path,
			data: source,
			headers: {
				'Content-Type': 'application/octet-stream'
			}
		};
		if (typeof statusCallback === 'function') {
			options.onUploadProgress = (progressEvent) => {
				let percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total );
				statusCallback(percentCompleted, this);
			};
		}

		let response = await this.connection._send(options);
		return this.setAll(response.data);
	}

	/**
	 * Deletes the file from the user workspace.
	 * 
	 * @async
	 * @throws {Error}
	 */
	async deleteFile() {
		await this.connection._delete('/files/' + this.userId + '/' + this.path);
	}
}

/**
 * A Batch Job.
 * 
 * @class
 * @extends BaseEntity
 */
class Job extends BaseEntity {

	/**
	 * Creates an object representing a batch job stored at the back-end.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {string} jobId - The batch job ID.
	 * @constructor
	 */
	constructor(connection, jobId) {
		super(connection, ["id", "title", "description", ["process_graph", "processGraph"], "status", "progress", "error", "submitted", "updated", "plan", "costs", "budget"]);
		this.jobId = jobId;
	}

	/**
	 * Updates the batch job data stored in this object by requesting the metadata from the back-end.
	 * 
	 * @async
	 * @returns {Job} The update job object (this).
	 * @throws {Error}
	 */
	async describeJob() {
		let response = await this.connection._get('/jobs/' + this.jobId);
		return this.setAll(response.data);
	}

	/**
	 * Modifies the batch job at the back-end and afterwards updates this object, too.
	 * 
	 * @async
	 * @param {object} parameters - An object with properties to update, each of them is optional, but at least one of them must be specified. Additional properties can be set if the server supports them.
	 * @param {object} parameters.processGraph - A new process graph.
	 * @param {string} parameters.title - A new title.
	 * @param {string} parameters.description - A new description.
	 * @param {string} parameters.plan - A new plan.
	 * @param {number} parameters.budget - A new budget.
	 * @returns {Job} The updated job object (this).
	 * @throws {Error}
	 */
	async updateJob(parameters) {
		await this.connection._patch('/jobs/' + this.jobId, this._convertToRequest(parameters));
		if (this._supports('describeJob')) {
			return await this.describeJob();
		}
		else {
			return this.setAll(parameters);
		}
	}

	/**
	 * Deletes the batch job from the back-end.
	 * 
	 * @async
	 * @throws {Error}
	 */
	async deleteJob() {
		await this.connection._delete('/jobs/' + this.jobId);
	}

	/**
	 * Calculate an estimate (potentially time/costs/volume) for a batch job.
	 * 
	 * @async
	 * @returns {object} A response compatible to the API specification.
	 * @throws {Error}
	 */
	async estimateJob() {
		let response = await this.connection._get('/jobs/' + this.jobId + '/estimate');
		return response.data;
	}

	/**
	 * Starts / queues the batch job for processing at the back-end.
	 * 
	 * @async
	 * @returns {Job} The updated job object (this).
	 * @throws {Error}
	 */
	async startJob() {
		await this.connection._post('/jobs/' + this.jobId + '/results', {});
		if (this._supports('describeJob')) {
			return await this.describeJob();
		}
		return this;
	}

	/**
	 * Stops / cancels the batch job processing at the back-end.
	 * 
	 * @async
	 * @returns {Job} The updated job object (this).
	 * @throws {Error}
	 */
	async stopJob() {
		await this.connection._delete('/jobs/' + this.jobId + '/results');
		if (this._supports('describeJob')) {
			return await this.describeJob();
		}
		return this;
	}

	/**
	 * Retrieves download links and additional information about the batch job.
	 * 
	 * NOTE: Requesting metalink XML files is currently not supported by the JS client.
	 * 
	 * @async
	 * @returns {object} The JSON-based response compatible to the API specification, but also including `costs` and `expires` properties as received in the headers (or `null` if not present).
	 * @throws {Error}
	 */
	async listResults() {
		let response = await this.connection._get('/jobs/' + this.jobId + '/results');
		// Returning null for missing headers is not strictly following the spec
		let headerData = {
			costs: response.headers['openeo-costs'] || null,
			expires: response.headers.expires || null
		};
		return Object.assign(headerData, response.data);
	}

	/**
	 * Downloads the results to the specified target folder. The specified target folder must already exist!
	 * 
	 * NOTE: This method is only supported in a NodeJS environment. In a browser environment this method throws an exception!
	 * 
	 * @async
	 * @param {string} targetFolder - A target folder to store the file to, which must already exist.
	 * @returns {string[]} A list of file paths of the newly created files.
	 * @throws {Error}
	 */
	async downloadResults(targetFolder) {
		if (isNode) {
			let list = await this.listResults();
			const url = require("url");
			const path = require("path");

			let files = [];
			const promises = list.links.map(async (link) => {
				let parsedUrl = url.parse(link.href);
				let targetPath = path.join(targetFolder, path.basename(parsedUrl.pathname));
				let response = await this.connection.download(link.href, false);
				await Util.saveToFileNode(response.data, targetPath);
				files.push(targetPath);
			});

			await Promise.all(promises);
			return files;
		}
		else {
			/* istanbul ignore next */
			throw new Error("downloadResults is not supported in a browser environment.");
		}
	}
}

/**
 * A Stored Process Graph.
 * 
 * @class
 * @extends BaseEntity
 */
class ProcessGraph extends BaseEntity {

	/**
	 * Creates an object representing a process graph stored at the back-end.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {string} processGraphId - ID of a stored process graph.
	 * @constructor
	 */
	constructor(connection, processGraphId) {
		super(connection, ["id", "title", "description", ["process_graph", "processGraph"]]);
		this.connection = connection;
		this.processGraphId = processGraphId;
	}

	/**
	 * Updates the data stored in this object by requesting the process graph metadata from the back-end.
	 * 
	 * @async
	 * @returns {ProcessGraph} The updated process graph object (this).
	 * @throws {Error}
	 */
	async describeProcessGraph() {
		let response = await this.connection._get('/process_graphs/' + this.processGraphId);
		return this.setAll(response.data);
	}

	/**
	 * Modifies the stored process graph at the back-end and afterwards updates this object, too.
	 * 
	 * @async
	 * @param {object} parameters - An object with properties to update, each of them is optional, but at least one of them must be specified. Additional properties can be set if the server supports them.
	 * @param {object} parameters.processGraph - A new process graph.
	 * @param {string} parameters.title - A new title.
	 * @param {string} parameters.description - A new description.
	 * @returns {ProcessGraph} The updated process graph object (this).
	 * @throws {Error}
	 */
	async updateProcessGraph(parameters) {
		await this.connection._patch('/process_graphs/' + this.processGraphId, this._convertToRequest(parameters));
		if (this._supports('describeProcessGraph')) {
			return this.describeProcessGraph();
		}
		else {
			return this.setAll(parameters);
		}
	}

	/**
	 * Deletes the stored process graph from the back-end.
	 * 
	 * @async
	 * @throws {Error}
	 */
	async deleteProcessGraph() {
		await this.connection._delete('/process_graphs/' + this.processGraphId);
	}
}

/**
 * A Secondary Web Service.
 * 
 * @class
 * @extends BaseEntity
 */
class Service extends BaseEntity {

	/**
	 * Creates an object representing a secondary web service stored at the back-end.
	 * 
	 * @param {Connection} connection - A Connection object representing an established connection to an openEO back-end.
	 * @param {string} serviceId - The service ID.
	 * @constructor
	 */
	constructor(connection, serviceId) {
		super(connection, ["id", "title", "description", ["process_graph", "processGraph"], "url", "type", "enabled", "parameters", "attributes", "submitted", "plan", "costs", "budget"]);
		this.serviceId = serviceId;
	}

	/**
	 * Updates the data stored in this object by requesting the secondary web service metadata from the back-end.
	 * 
	 * @async
	 * @returns {Service} The updates service object (this).
	 * @throws {Error}
	 */
	async describeService() {
		let response = await this.connection._get('/services/' + this.serviceId);
		return this.setAll(response.data);
	}

	/**
	 * Modifies the secondary web service at the back-end and afterwards updates this object, too.
	 * 
	 * @async
	 * @param {object} parameters - An object with properties to update, each of them is optional, but at least one of them must be specified. Additional properties can be set if the server supports them.
	 * @param {object} parameters.processGraph - A new process graph.
	 * @param {string} parameters.title - A new title.
	 * @param {string} parameters.description - A new description.
	 * @param {boolean} parameters.enabled - Enables (`true`) or disables (`false`) the service.
	 * @param {object} parameters.parameters - A new set of parameters to set for the service.
	 * @param {string} parameters.plan - A new plan.
	 * @param {number} parameters.budget - A new budget.
	 * @returns {Service} The updated service object (this).
	 * @throws {Error}
	 */
	async updateService(parameters) {
		await this.connection._patch('/services/' + this.serviceId, this._convertToRequest(parameters));
		if (this._supports('describeService')) {
			return await this.describeService();
		}
		else {
			return this.setAll(parameters);
		}
	}

	/**
	 * Deletes the secondary web service from the back-end.
	 * 
	 * @async
	 * @throws {Error}
	 */
	async deleteService() {
		await this.connection._delete('/services/' + this.serviceId);
	}
}

/**
 * Utilities for the openEO JS Client.
 * 
 * @class
 * @hideconstructor
 */
class Util {

	/**
	 * Normalize a URL (mostly handling slashes).
	 * 
	 * @static
	 * @param {string} baseUrl - The URL to normalize
	 * @param {string} path - An optional path to add to the URL
	 * @returns {string} Normalized URL.
	 */
	static normalizeUrl(baseUrl, path = null) {
		let url = baseUrl.replace(/\/$/, ""); // Remove trailing slash
		if (typeof path === 'string') {
			if (path.substr(0, 1) !== '/') {
				path = '/' + path; // Add leading slash
			}
			url = url + path;
		}
		return url;
	}

	/**
	 * Encodes a string into Base64 encoding.
	 * 
	 * @static
	 * @param {string} str - String to encode.
	 * @returns {string} String encoded in Base64.
	 */
	static base64encode(str) {
		if (typeof btoa === 'function') {
			// btoa is JS's ugly name for encodeBase64
			return btoa(str);
		}
		else {
			let buffer;
			if (str instanceof Buffer) {
				buffer = str;
			} else {
				buffer = Buffer.from(str.toString(), 'binary');
			}
			return buffer.toString('base64');
		}
	}

	/**
	 * Non-crypthographic (unsafe) hashing for objects.
	 * 
	 * Internally uses Jenkins one_at_a_time hash function.
	 * 
	 * @static
	 * @param {*} o - A value to encode. Only supported objects, arrays, strings, numbers and booleans. Can't encode functions or other data types.
	 * @returns {string} The generated hash.
	 * @see https://en.wikipedia.org/wiki/Jenkins_hash_function
	 */
	static hash(o) {
		switch(typeof o) {
			case 'boolean':
				return Util.hashString("b:" + o.toString());
			case 'number':
				return Util.hashString("n:" + o.toString());
			case 'string':
				return Util.hashString("s:" + o);
			case 'object':
				if (o === null) {
					return Util.hashString("n:");
				}
				else {
					return Util.hashString(Object.keys(o).sort().map(k => "o:" + k + ":" + Util.hash(o[k])).join("::"));
				}
				/* falls through */
			default:
				return Util.hashString(typeof o);
		}
	}

	/**
	 * Generates a hash for a string using Jenkins one_at_a_time hash function.
	 * 
	 * @static
	 * @param {string} b - string to encode.
	 * @returns {string} The generated hash.
	 * @see https://en.wikipedia.org/wiki/Jenkins_hash_function
	 */
	static hashString(b) {
		let a, c;
		for(a = 0, c = b.length; c--; ) {
			a += b.charCodeAt(c);
			a += a<<10;
			a ^= a>>6;
		}
		a += a<<3;
		a ^= a>>11;
		a += a<<15;
		return ((a&4294967295)>>>0).toString(16);
	}

	/**
	 * Streams data into a file.
	 * 
	 * NOTE: Only supported in a NodeJS environment.
	 *
	 * @static
	 * @async
	 * @param {Stream} data - Data stream to read from.
	 * @param {string} filename - File path to store the data at.
	 * @throws {Error}
	 */
	static async saveToFileNode(data, filename) {
		const fs = require('fs');
		return new Promise((resolve, reject) => {
			let writeStream = fs.createWriteStream(filename);
			writeStream.on('close', (err) => {
				if (err) {
					return reject(err);
				}
				resolve();
			});
			data.pipe(writeStream);
		});
	}

	/**
	 * Offers data to download in the browser.
	 * 
	 * NOTE: Only supported in a browser environment.
	 * This method may fail with overly big data.
	 * 
	 * @static
	 * @param {*} data - Data to download.
	 * @param {string} filename - File name that is suggested to the user.
	 * @see https://github.com/kennethjiang/js-file-download/blob/master/file-download.js
	 */
	/* istanbul ignore next */
	static saveToFileBrowser(data, filename) {
		let blob = new Blob([data], {type: 'application/octet-stream'});
		let blobURL = window.URL.createObjectURL(blob);
		let tempLink = document.createElement('a');
		tempLink.style.display = 'none';
		tempLink.href = blobURL;
		tempLink.setAttribute('download', filename); 
		
		if (typeof tempLink.download === 'undefined') {
			tempLink.setAttribute('target', '_blank');
		}
		
		document.body.appendChild(tempLink);
		tempLink.click();
		document.body.removeChild(tempLink);
		window.URL.revokeObjectURL(blobURL);
	}

	/**
	 * Tries to guess the most suitable version from a well-known discovery document that this client is compatible to.
	 * 
	 * @static
	 * @param {object} versions - A well-known discovery document compliant to the API specification.
	 * @returns {object[]} - Gives a list that lists all compatible versions (as still API compliant objects) ordered from the most suitable to the least suitable.
	 */
	static mostCompatible(versions) {
		if (!Array.isArray(versions)) {
			return [];
		}

		let compatible = versions.filter(c => typeof c.url === 'string' && Util.validateVersionNumber(c.api_version) && c.api_version.startsWith("0.4."));
		if (compatible.length === 0) {
			return [];
		}

		return compatible.sort((c1, c2) => {
			let p1 = c1.production !== false;
			let p2 = c2.production !== false;
			if (p1 === p2) {
				return Util.compareVersionNumbers(c1.api_version, c2.api_version) * -1; // `* -1` to sort in descending order.
			}
			else if (p1) {
				return -1;
			}
			else if (p2) {
				return 1;
			}
			else {
				return 0;
			}
		});
	}

	/**
	 * Validates a version number.
	 * 
	 * Handles only numeric version numbers separated by dots, doesn't allow wildcards or pre-release strings as `alpha` or `beta`.
	 * 
	 * @param {string} v - A version number.
	 * @returns {boolean} - `true` if valid, `false` otherwise.
	 */
	static validateVersionNumber(v) {
		if (typeof v !== 'string') {
			return false;
		}

		let parts = v.split('.');
		for (var i = 0; i < parts.length; ++i) {
			if (!/^\d+$/.test(parts[i])) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Compares two version number.
	 * 
	 * Handles only numeric version numbers separated by dots, doesn't allow wildcards or pre-release strings as `alpha` or `beta`.
	 * 
	 * @param {string} v1 - A version number.
	 * @param {string} v2 - Another version number.
	 * @returns {integer|null} - `1` if v1 is greater than v2, `-1` if v1 is less than v2 and `0` if v1 and v2 are equal. Returns `null` if any of the version numbers is invalid.
	 * @see Util.validateVersionNumber()
	 */
	static compareVersionNumbers(v1, v2) {
		// First, validate both numbers are true version numbers
		if (!Util.validateVersionNumber(v1) || !Util.validateVersionNumber(v2)) {
			return null;
		}
	
		let v1p = v1.split('.');
		let v2p = v2.split('.');
		for (var i = 0; i < Math.max(v1p.length, v2p.length); ++i) {
			let left = typeof v1p[i] !== 'undefined' ? v1p[i] : 0;
			let right = typeof v2p[i] !== 'undefined' ? v2p[i] : 0;
			if (left < right) {
				return -1;
			}
			else if (left > right) {
				return 1;
			}
		}
	
		return 0;
	}

	/**
	 * Checks whether a variable is a real object or not.
	 * 
	 * This is a more strict version of `typeof x === 'object'` as this example would also succeeds for arrays and `null`.
	 * This function only returns `true` for real objects and not for arrays, `null` or any other data types.
	 * 
	 * @param {*} obj - A variable to check.
	 * @returns {boolean} - `true` is the given variable is an object, `false` otherwise.
	 */
	static isObject(obj) {
		return (typeof obj === 'object' && obj === Object(obj) && !Array.isArray(obj));
	}
}

/** @module OpenEO */
let toExport = {
	OpenEO: OpenEO,
	Capabilities: Capabilities,
	Util: Util
};

/*
 * @see https://www.matteoagosti.com/blog/2013/02/24/writing-javascript-modules-for-both-browser-and-node/
 */
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
	module.exports = toExport;
}
else {
	/* istanbul ignore next */
	if (typeof define === 'function' && define.amd) {
		define([], function () {
			return toExport;
		});
	}
	else {
		for(let exportObjName in toExport) {
			window[exportObjName] = toExport[exportObjName];
		}
	}
}