ember-horizon
Version:
An Ember service for managing a Horizon connection and an adapter to request data from that connection.
374 lines (327 loc) • 10.6 kB
JavaScript
import Ember from 'ember';
import config from 'ember-get-config';
import workflow from '../utils/workflow';
import Watching from '../mixins/watching';
const { RSVP: {Promise}, computed, debug, get, typeOf, $, assert } = Ember;
const hzConfig = Ember.assign({ host: 'http://localhost:8181' }, get(config, 'horizon') || {});
let hz;
if(window.Horizon) {
hz = window.Horizon(hzConfig);
} else {
console.warn('Horizon client library wasn\'t available. Please be sure that the Horizon server is running.');
}
const makeArray = function(hash) {
return Object.keys(hash).map(i => {
return { id: i, url: hash[i] };
});
}
/**
* @class Horizon
*
* Service methods for interacting with Horizon
*/
export default Ember.Service.extend(Watching, {
ajax: Ember.inject.service(),
// Observable members
currentUser: computed('isLoggedIn', function() {
return hz.currentUser();
}).volatile(),
status: 'unconnected',// set by Horizon Observable
hasAuthToken: computed.alias('isLoggedIn'), // set by Horizon Observable
raw: true, // specifies detail/structure in watched changes (true = RethinkDB changestream)
willDestroy() {
this._super(...arguments);
this.disconnect();
},
connect() {
return new Promise((resolve, reject) => {
const status = get(this, 'status');
if(status === 'ready') {
resolve();
} else {
this.loadClientDriver()
.then(() => {
// Client driver is loaded
hz.connect();
debug('Connecting to Horizon ...');
hz.onReady(() => {
this.set('status', 'ready');
debug('Horizon connected');
this.listenForCurrentUser();
resolve();
});
hz.onDisconnected(() => {
this._disconnected();
reject({code: 'disconnected', config: hz});
});
hz.onSocketError(() => {
this._socketError();
reject({code: 'socket-error'});
});
})
.catch(() => {
// client driver failed
this.set('status', 'error');
const err = {
code: 'failed-to-reach-horizon-client-driver',
when: new Date(),
url: $('#client-driver').attr('src')
};
console.warn('Horizon client library is still unreachable!', err);
this.errors = this.errors ? [].concat(this.errors, err) : [].concat(err);
});
}
}); // return promise
},
listenForCurrentUser() {
hz.currentUser().fetch().subscribe( user => {
this.set('currentUser', user);
});
},
disconnect() {
debug('Disconnecting from Horizon server');
this.logout();
},
/**
* Returns a list of Authentication services which are configured for the Horizon Server
*/
getAuthServices() {
return get(this, 'ajax').request(`${this.horizonServerURI()}/horizon/auth_methods`);
},
/**
* Given a service name (e.g, 'facebook', 'google'), it will force browser to authenticate
* against the indicated service. If you are embedding into an iFrame then you must
* the DOM element which is your iFrame
*/
authenticate(service, domElement = null) {
if(domElement) {
// TODO: implement
} else {
// TODO: this has a known CORs issue with it at the moment
// hz.authEndpoint(service).subscribe(endpoint => {
// window.location.replace(endpoint);
// });
this.getAuthServices()
.then(services => {
const authUrl = makeArray(services).filter(s => s.id === service)[0].url;
console.log(`${this.horizonServerURI()}${authUrl}`);
window.location.replace(`${this.horizonServerURI()}${authUrl}`);
});
}
},
/**
* Logs out the current user
*/
logout() {
// TODO: the below command is the RIGHT way to do this
if(window.Horizon) {
window.Horizon.clearAuthTokens();
} else {
debug(`couldn't find horizon client library; manually removed token from local storage`);
window.localStorage.removeItem('horizon-jwt');
}
},
/**
* Typically the Horizon client driver should load with page load but if it doesn't for some
* reason we will retry to load it with this call
*/
loadClientDriver() {
return new Promise((resolve, reject) => {
if(!hz) {
const driver = $('#client-driver').attr('src');
debug(`The Horizon client library wasn't available at page load, retrying now [${driver}].`);
$.getScript(driver)
.done((script, textStatus) => {
debug('Was able to load Horizon client library: ', textStatus);
hz = window.Horizon(config.horizon || {});
resolve();
})
.fail(err => {
reject(err);
});
} else {
resolve();
}
}); // return promise
},
/**
* Converts a model name into a Horizon collection object
*
* @param {mixed} state Takes as input one of the following:
* 1. A "state" name/value hash with property 'model' set,
* 2. A string with the name of the model
* @return {Object} An object with the "collection" property set to the
* Horizon collection object
*/
collection(state) {
state = typeOf(state) === 'string' ? {model: state} : state;
const model = state.model;
return new Promise((resolve, reject) => {
this.connect()
.then(() => {
state.collection = hz(model);
state.workflow = workflow(state, 'collection');
resolve(state);
})
.catch(err => {
console.error(`Problem getting "${model}" collection: `, err);
reject(err);
});
}); // return promise
},
isLoggedIn: computed('hz', function() {
return hz && hz.hasAuthToken();
}).volatile(),
authEndpoint() {
return hz.authEndpoint(...arguments);
},
horizonServerURI() {
const protocol = hzConfig.secure ? 'https://' : 'http://';
return protocol + hzConfig.host;
},
/**
* Allows the execution of a Collection.find primative,
* returning a chainable Collection object
*
* @param {Object} state a "state" hash with a property "collection" available,
* optionally with "filterBy"
* @return {Object} state hash
*/
find(state) {
// inputs
const {collection} = state;
const filterBy = state.filterBy || state.id;
// promise
return new Promise((resolve, reject) => {
if(!collection) {
const message = `Horizon "find" called but state didn't have collection property`;
console.error(message);
reject({code: 'no-collection-property', message: message});
return;
}
if (filterBy) {
state.collection = collection.find(filterBy);
state.workflow = workflow(state,'find');
return resolve(state);
} else {
reject({code: "find-requires-filter-by"});
}
}); // return promise
},
findMany(state) {
// inputs
const {collection} = state;
const filterBy = (state.findBy || state.ids || []).map(f => {
return typeOf(f) === 'object' ? f : {id: f};
});
state.filterBy = filterBy;
// promise
return new Promise((resolve, reject) => {
if(!collection) {
const message = `Horizon "find" called but state didn't have collection property`;
assert(message, this);
reject({code: 'no-collection-property', message: message});
return;
}
if (filterBy) {
// update state to include collection
// with filtered scope from filterBy query
const c2 = collection.findAll(...filterBy);
state.collection = c2;
state.workflow = workflow(state,'findMany');
resolve(state);
} else {
reject({code: "find-requires-filter-criteria", state: state});
}
}); // return promise
},
/**
* Takes in a collection object and runs both fetch() and subscribe()
* to get a result from the collection.
*
* @param {Object} state receives all relevant local scope
* @return {Object} returns the JSON payload returned from subscribe()
*/
fetch(state) {
// inputs
const {collection} = state;
// promise
return new Promise((resolve, reject) => {
collection.fetch().subscribe(
result => {
state.workflow = workflow(state, 'fetch');
state.payload = result;
resolve(state);
},
err => reject(err)
);
}); // return promise
},
store(state) {
// inputs
const {collection, payload} = state;
// promise
return new Promise((resolve, reject) => {
collection.store(payload).subscribe(
serverProperties => {
state.payload = Ember.assign(payload, serverProperties);
state.workflow = workflow(state, 'store');
resolve(state);
},
err => reject(err)
);
}); // return promise
},
/**
* Replaces an existing record in a RethinkDB collection
* with a full replacement JSON payload (PUT not PATCH in REST terminology)
*
* @param {Object} state local state including connection and payload
* @return {Promise}
*/
replace(state) {
// inputs
const {collection, payload} = state;
// promise
return new Promise((resolve, reject) => {
collection.replace(payload).subscribe(
id => resolve(id),
err => reject(err)
);
});
},
remove(state) {
// inputs
const {collection, id} = state;
// promise
return new Promise((resolve, reject) => {
collection.remove(id).subscribe(
() => {
state.workflow = workflow(state, 'remove');
resolve(state);
},
err => reject(err)
);
}); // return promise
},
_disconnected() {
this.set('status', 'disconnected');
debug('horizon is disconnected');
this._retryConnection();
},
_socketError() {
this.set('status', 'error');
debug('horizon is in an error state');
this._retryConnection();
},
_retryConnection() {
const delays = [1000, 5000, 15000, 60000];
delays.forEach(delay => {
Ember.run.later(() => {
if (this.get('status') === 'disconnected') {
this.connect();
}
}, delay);
});
},
});