diff --git a/README.md b/README.md index 691e4027..2c56cb07 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ JavaScript client for the openEO API. [![Build Status](https://travis-ci.org/Open-EO/openeo-js-client.svg?branch=master)](https://travis-ci.org/Open-EO/openeo-js-client) -This client is in **version 0.3.0** and supports **openEO API versions 0.3.0 and 0.3.1**. Legacy versions are available as releases. +This client is in **version 0.3.1** and supports **openEO API versions 0.3.0 and 0.3.1**. Legacy versions are available as releases. ## Usage This library can run in a recent browser supporting ECMAScript 2015 or node.js. diff --git a/openeo.js b/openeo.js index a5e2f80a..31f19789 100644 --- a/openeo.js +++ b/openeo.js @@ -42,30 +42,30 @@ class OpenEO { class Connection { constructor(baseUrl) { - this._baseUrl = baseUrl; - this._userId = null; - this._token = null; - this._subscriptions = new Subscriptions(this); - this._capabilitiesCache = null; + this.baseUrl = baseUrl; + this.userId = null; + this.accessToken = null; + this.capabilitiesObject = null; + this.subscriptionsObject = new Subscriptions(this); } getBaseUrl() { - return this._baseUrl; + return this.baseUrl; } getUserId() { - return this._userId; + return this.userId; } capabilities() { - if (this._capabilitiesCache === null) { + if (this.capabilitiesObject === null) { return this._get('/').then(response => { - this._capabilitiesCache = new Capabilities(response.data); - return this._capabilitiesCache; + this.capabilitiesObject = new Capabilities(response.data); + return this.capabilitiesObject; }); } else { - return Promise.resolve(this._capabilitiesCache); + return Promise.resolve(this.capabilitiesObject); } } @@ -121,8 +121,8 @@ class Connection { if (!response.data.access_token) { throw new Error("No access_token returned."); } - this._userId = response.data.user_id; - this._token = response.data.access_token; + this.userId = response.data.user_id; + this.accessToken = response.data.access_token; return response.data; }).catch(error => { this._resetAuth(); @@ -137,22 +137,22 @@ class Connection { listFiles(userId = null) { // userId defaults to authenticated user if(userId === null) { - if(this._userId === null) { + if(this.userId === null) { return Promise.reject(new Error("Parameter 'userId' not specified and no default value available because user is not logged in.")); } else { - userId = this._userId; + userId = this.userId; } } return this._get('/files/' + userId) - .then(response => response.data.files.map((f) => new File(this, userId, f.name)._addMetadata(f))); + .then(response => response.data.files.map((f) => new File(this, userId, f.name).setAll(f))); } createFile(name, userId = null) { // userId defaults to authenticated user if(userId === null) { - if(this._userId === null) { + if(this.userId === null) { return Promise.reject(new Error("Parameter 'userId' not specified and no default value available because user is not logged in.")); } else { - userId = this._userId; + userId = this.userId; } } return Promise.resolve(new File(this, userId, name)); @@ -160,18 +160,27 @@ class Connection { validateProcessGraph(processGraph) { return this._post('/validation', {process_graph: processGraph}) - .then(_ => [true, {}]) // Accepts other status codes than 204, which is not strictly following the spec + .then(() => [true, {}]) // Accepts other status codes than 204, which is not strictly following the spec .catch(error => [false, error.response.data]); } listProcessGraphs() { return this._get('/process_graphs') - .then(response => response.data.process_graphs.map((pg) => new ProcessGraph(this, pg.process_graph_id)._addMetadata(pg))); + .then(response => response.data.process_graphs.map((pg) => new ProcessGraph(this, pg.process_graph_id).setAll(pg))); } createProcessGraph(processGraph, title = null, description = null) { - return this._post('/process_graphs', {title: title, description: description, process_graph: processGraph}) - .then(response => new ProcessGraph(this, response.headers['OpenEO-Identifier'] || response.headers['openeo-identifier'])._addMetadata({title: title, description: description})); + var pgObject = {title: title, description: description, process_graph: processGraph}; + return this._post('/process_graphs', pgObject) + .then(response => new ProcessGraph(this, response.headers['openeo-identifier']).setAll(pgObject)) + .then(pg => { + if (this.capabilitiesObject.hasFeature('describeProcessGraph')) { + return pg.describeProcessGraph(); + } + else { + return Promise.resolve(pg); + } + }); } execute(processGraph, outputFormat = null, outputParameters = {}, budget = null) { @@ -191,7 +200,7 @@ class Connection { listJobs() { return this._get('/jobs') - .then(response => response.data.jobs.map(j => new Job(this, j.job_id)._addMetadata(j))); + .then(response => response.data.jobs.map(j => new Job(this, j.job_id).setAll(j))); } createJob(processGraph, outputFormat = null, outputParameters = {}, title = null, description = null, plan = null, budget = null, additional = {}) { @@ -209,12 +218,20 @@ class Connection { }; } return this._post('/jobs', jobObject) - .then(response => new Job(this, response.headers['OpenEO-Identifier'] || response.headers['openeo-identifier'])._addMetadata({title: title, description: description})); + .then(response => new Job(this, response.headers['openeo-identifier']).setAll(jobObject)) + .then(job => { + if (this.capabilitiesObject.hasFeature('describeJob')) { + return job.describeJob(); + } + else { + return Promise.resolve(job); + } + }); } listServices() { return this._get('/services') - .then(response => response.data.services.map((s) => new Service(this, s.service_id)._addMetadata(s))); + .then(response => response.data.services.map((s) => new Service(this, s.service_id).setAll(s))); } createService(processGraph, type, title = null, description = null, enabled = true, parameters = {}, plan = null, budget = null) { @@ -229,7 +246,15 @@ class Connection { budget: budget }; return this._post('/services', serviceObject) - .then(response => new Service(this, response.headers['OpenEO-Identifier'] || response.headers['openeo-identifier'])._addMetadata({title: title, description: description})); + .then(response => new Service(this, response.headers['openeo-identifier']).setAll(serviceObject)) + .then(service => { + if (this.capabilitiesObject.hasFeature('describeService')) { + return service.describeService(); + } + else { + return Promise.resolve(service); + } + }); } _get(path, query, responseType) { @@ -261,15 +286,6 @@ class Connection { }); } - _put(path, body, contenttype = undefined) { - return this._send({ - method: 'put', - url: path, - data: body, - headers: (contenttype == undefined ? {} : { 'content-type': contenttype }) - }); - } - _delete(path) { return this._send({ method: 'delete', @@ -290,13 +306,13 @@ class Connection { } _send(options) { - options.baseURL = this._baseUrl; + 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._token; + options.headers['Authorization'] = 'Bearer ' + this.accessToken; } if (options.responseType == 'stream' && !isNode) { options.responseType = 'blob'; @@ -304,25 +320,32 @@ class Connection { if (!options.responseType) { options.responseType = 'json'; } + switch(options.method) { + case 'put': + case 'patch': + case 'delete': + options.validateStatus = status => status == 204; + break; + } return axios(options); } _resetAuth() { - this._userId = null; - this._token = null; + this.userId = null; + this.accessToken = null; } isLoggedIn() { - return (this._token !== null); + return (this.accessToken !== null); } subscribe(topic, parameters, callback) { - return this._subscriptions.subscribe(topic, parameters, callback); + return this.subscriptionsObject.subscribe(topic, parameters, callback); } unsubscribe(topic, parameters, callback) { - return this._subscriptions.unsubscribe(topic, parameters, callback); + return this.subscriptionsObject.unsubscribe(topic, parameters, callback); } _saveToFileNode(data, filename) { @@ -421,7 +444,7 @@ class Subscriptions { _createWebSocket() { if (this.socket === null || this.socket.readyState === this.socket.CLOSING || this.socket.readyState === this.socket.CLOSED) { this.messageQueue = []; - var url = this.httpConnection._baseUrl.replace('http', 'ws') + '/subscription'; + var url = this.httpConnection.getBaseUrl().replace('http', 'ws') + '/subscription'; if (isNode) { const WebSocket = require('ws'); @@ -485,7 +508,7 @@ class Subscriptions { _sendMessage(topic, payload = null, priority = false) { var obj = { - authorization: "Bearer " + this.httpConnection._token, + authorization: "Bearer " + this.httpConnection.accessToken, message: { topic: "openeo." + topic, issued: (new Date()).toISOString() @@ -553,7 +576,7 @@ class Capabilities { authenticateBasic: 'GET /credentials/basic', describeAccount: 'GET /me', listFiles: 'GET /files/{user_id}', - validateProcessGraph: 'POST /validate', + validateProcessGraph: 'POST /validation', createProcessGraph: 'POST /process_graphs', listProcessGraphs: 'GET /process_graphs', execute: 'POST /preview', @@ -577,7 +600,9 @@ class Capabilities { deleteProcessGraph: 'DELETE /process_graphs/{process_graph_id}', describeService: 'GET /services/{service_id}', updateService: 'PATCH /services/{service_id}', - deleteService: 'DELETE /services/{service_id}' + deleteService: 'DELETE /services/{service_id}', + subscribe: 'GET /subscription', + unsubscribe: 'GET /subscription' }; // regex-ify to allow custom parameter names @@ -586,8 +611,7 @@ class Capabilities { } if (methodName === 'createFile') { - return true; // Of course it's always possible to create "a (virtual) file". - // But maybe it would be smarter to return the value of hasFeature('uploadFile') instead, because that's what the user most likely wants to do + return this.hasFeature('uploadFile'); // createFile is always available, map it to uploadFile as it is more meaningful. } else { return this._data.endpoints .map((e) => e.methods.map((method) => method + ' ' + e.path)) @@ -598,35 +622,71 @@ class Capabilities { } currency() { - return (this._data.billing ? this._data.billing.currency : undefined); + return (this._data.billing ? this._data.billing.currency : null); } listPlans() { - return (this._data.billing ? this._data.billing.plans : undefined); + return (this._data.billing ? this._data.billing.plans : null); } } -class File { - constructor(connection, userId, name) { +class BaseEntity { + + constructor(connection, properties = []) { this.connection = connection; - this.userId = userId; - this.name = name; + this.clientNames = {}; + this.extra = {}; + for(var i in properties) { + var 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; + if (typeof this[client] === 'undefined') { + this[client] = null; + } + } } - _addMetadata(metadata) { - // Metadata for files can be "size", "modified", or ANY (!) custom field name. - // To prevent overwriting of already existing data we therefore have to delete keys that already - // exist in "this" scope from the metadata object (if they exist) - delete metadata.connection; - delete metadata.userId; - delete metadata.name; + setAll(metadata) { + for (var name in metadata) { + if (typeof this.clientNames[name] === 'undefined') { + this.extra[name] = metadata[name]; + } + else { + this[this.clientNames[name]] = metadata[name]; + } + } + return this; + } - for(var md in metadata) { - this[md] = metadata[md]; + getAll() { + var obj = {}; + for (var backend in this.clientNames) { + var client = this.clientNames[backend]; + obj[client] = this[client]; } + return Object.assign(obj, this.extra); + } + + get(name) { + return typeof this.extra[name] !== 'undefined' ? this.extra[name] : null; + } + +} - return this; // for chaining + +class File extends BaseEntity { + constructor(connection, userId, name) { + super(connection, ["name", "size", "modified"]); + this.userId = userId; + this.name = name; } // If target is null, returns promise with data as stream in node environment, blob in browser. @@ -653,8 +713,39 @@ class File { } } - uploadFile(source) { - return this.connection._put('/files/' + this.userId + '/' + this.name, source, 'application/octet-stream'); + _readFromFileNode(path) { + var fs = require('fs'); + return fs.createReadStream(path); + } + + // source for node must be a path to a file as string + // source for browsers must be an object from a file upload form + 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 + + var options = { + method: 'put', + url: '/files/' + this.userId + '/' + this.name, + data: source, + headers: { + 'Content-Type': 'application/octet-stream' + } + }; + if (typeof statusCallback === 'function') { + options.onUploadProgress = function(progressEvent) { + var percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); + statusCallback(percentCompleted); + }; + } + + // ToDo: We should set metadata here for convenience as in createJob etc., but the API gives no information. + return this.connection._send(options).then(() => { + return this; + }); } deleteFile() { @@ -663,37 +754,32 @@ class File { } -class Job { +class Job extends BaseEntity { constructor(connection, jobId) { - this.connection = connection; + super(connection, [["job_id", "jobId"], "title", "description", ["process_graph", "processGraph"], "output", "status", "submitted", "updated", "plan", "costs", "budget"]); this.jobId = jobId; } - _addMetadata(metadata) { - this.title = metadata.title; - this.description = metadata.description; - this.status = metadata.status; - this.submitted = metadata.submitted; - this.updated = metadata.updated; - this.plan = metadata.plan; - this.costs = metadata.costs; - this.budget = metadata.budget; - return this; // for chaining - } - describeJob() { return this.connection._get('/jobs/' + this.jobId) - .then(response => response.data); + .then(response => this.setAll(response.data)); } updateJob(parameters) { return this.connection._patch('/jobs/' + this.jobId, parameters) - .then(response => response.status == 204); + .then(() => { + if (this.connection.capabilitiesObject.hasFeature('describeJob')) { + return this.describeJob(); + } + else { + this.setAll(parameters); + return Promise.resolve(this); + } + }); } deleteJob() { - return this.connection._delete('/jobs/' + this.jobId) - .then(response => response.status == 204); + return this.connection._delete('/jobs/' + this.jobId); } estimateJob() { @@ -703,12 +789,26 @@ class Job { startJob() { return this.connection._post('/jobs/' + this.jobId + '/results', {}) - .then(response => response.status == 202); + .then(() => { + if (this.connection.capabilitiesObject.hasFeature('describeJob')) { + return this.describeJob(); + } + else { + return Promise.resolve(this); + } + }); } stopJob() { return this.connection._delete('/jobs/' + this.jobId + '/results') - .then(response => response.status == 204); + .then(() => { + if (this.connection.capabilitiesObject.hasFeature('describeJob')) { + return this.describeJob(); + } + else { + return Promise.resolve(this); + } + }); } listResults(type = 'json') { @@ -757,67 +857,62 @@ class Job { } -class ProcessGraph { +class ProcessGraph extends BaseEntity { constructor(connection, processGraphId) { + super(connection, [["process_graph_id", "processGraphId"], "title", "description", ["process_graph", "processGraph"]]); this.connection = connection; this.processGraphId = processGraphId; } - _addMetadata(metadata) { - this.title = metadata.title; - this.description = metadata.description; - return this; // for chaining - } - describeProcessGraph() { return this.connection._get('/process_graphs/' + this.processGraphId) - .then(response => response.data); + .then(response => this.setAll(response.data)); } updateProcessGraph(parameters) { return this.connection._patch('/process_graphs/' + this.processGraphId, parameters) - .then(response => response.status == 204); + .then(() => { + if (this.connection.capabilitiesObject.hasFeature('describeProcessGraph')) { + return this.describeProcessGraph(); + } + else { + this.setAll(parameters); + return Promise.resolve(this); + } + }); } deleteProcessGraph() { - return this.connection._delete('/process_graphs/' + this.processGraphId) - .then(response => response.status == 204); + return this.connection._delete('/process_graphs/' + this.processGraphId); } } -class Service { +class Service extends BaseEntity { constructor(connection, serviceId) { - this.connection = connection; + super(connection, [["service_id", "serviceId"], "title", "description", ["process_graph", "processGraph"], "url", "type", "enabled", "parameters", "attributes", "submitted", "plan", "costs", "budget"]); this.serviceId = serviceId; } - _addMetadata(metadata) { - this.title = metadata.title; - this.description = metadata.description; - this.url = metadata.url; - this.type = metadata.type; - this.enabled = metadata.enabled; - this.submitted = metadata.submitted; - this.plan = metadata.plan; - this.costs = metadata.costs; - this.budget = metadata.budget; - return this; // for chaining - } - describeService() { return this.connection._get('/services/' + this.serviceId) - .then(response => response.data); + .then(response => this.setAll(response.data)); } updateService(parameters) { return this.connection._patch('/services/' + this.serviceId, parameters) - .then(response => response.status == 204); + .then(() => { + if (this.connection.capabilitiesObject.hasFeature('describeService')) { + return this.describeService(); + } + else { + return Promise.resolve(this.setAll(parameters)); + } + }); } deleteService() { - return this.connection._delete('/services/' + this.serviceId) - .then(response => response.status == 204); + return this.connection._delete('/services/' + this.serviceId); } } diff --git a/package.json b/package.json index 75d8a82f..18e964b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openeo/js-client", - "version": "0.3.0", + "version": "0.3.1", "author": "openEO Consortium", "contributors": [ {