[Persistence] Copy CouchDB adapter

Copy CouchDB adapter to use as a basis for an adapter to
ElasticSearch, WTD-1033.
This commit is contained in:
Victor Woeltjen 2015-03-19 16:48:04 -07:00
parent 6721bca32a
commit 37310443e6
9 changed files with 678 additions and 0 deletions

View File

@ -0,0 +1,2 @@
This bundle implements a connection to an external ElasticSearch persistence
store in Open MCT Web.

View File

@ -0,0 +1,39 @@
{
"name": "Couch Persistence",
"description": "Adapter to read and write objects using a CouchDB instance.",
"extensions": {
"components": [
{
"provides": "persistenceService",
"type": "provider",
"implementation": "CouchPersistenceProvider.js",
"depends": [ "$http", "$q", "PERSISTENCE_SPACE", "COUCHDB_PATH" ]
}
],
"constants": [
{
"key": "PERSISTENCE_SPACE",
"value": "mct"
},
{
"key": "COUCHDB_PATH",
"value": "/couch/openmct"
},
{
"key": "COUCHDB_INDICATOR_INTERVAL",
"value": 15000
}
],
"indicators": [
{
"implementation": "CouchIndicator.js",
"depends": [
"$http",
"$interval",
"COUCHDB_PATH",
"COUCHDB_INDICATOR_INTERVAL"
]
}
]
}
}

View File

@ -0,0 +1,41 @@
/*global define*/
define(
[],
function () {
"use strict";
/**
* A CouchDocument describes domain object model in a format
* which is easily read-written to CouchDB. This includes
* Couch's _id and _rev fields, as well as a sseparate
* metadata field which contains a subset of information found
* in the model itself (to support search optimization with
* CouchDB views.)
* @constructor
* @param {string} id the id under which to store this mode
* @param {object} model the model to store
* @param {string} rev the revision to include (or undefined,
* if no revision should be noted for couch)
* @param {boolean} whether or not to mark this documnet as
* deleted (see CouchDB docs for _deleted)
*/
function CouchDocument(id, model, rev, markDeleted) {
return {
"_id": id,
"_rev": rev,
"_deleted": markDeleted,
"metadata": {
"category": "domain object",
"type": model.type,
"owner": "admin",
"name": model.name,
"created": Date.now()
},
"model": model
};
}
return CouchDocument;
}
);

View File

@ -0,0 +1,103 @@
/*global define*/
define(
[],
function () {
"use strict";
// Set of connection states; changing among these states will be
// reflected in the indicator's appearance.
// CONNECTED: Everything nominal, expect to be able to read/write.
// DISCONNECTED: HTTP failed; maybe misconfigured, disconnected.
// SEMICONNECTED: Connected to the database, but it reported an error.
// PENDING: Still trying to connect, and haven't failed yet.
var CONNECTED = {
text: "Connected",
glyphClass: "ok",
description: "Connected to the domain object database."
},
DISCONNECTED = {
text: "Disconnected",
glyphClass: "err",
description: "Unable to connect to the domain object database."
},
SEMICONNECTED = {
text: "Unavailable",
glyphClass: "caution",
description: "Database does not exist or is unavailable."
},
PENDING = {
text: "Checking connection..."
};
/**
* Indicator for the current CouchDB connection. Polls CouchDB
* at a regular interval (defined by bundle constants) to ensure
* that the database is available.
*/
function CouchIndicator($http, $interval, PATH, INTERVAL) {
// Track the current connection state
var state = PENDING;
// Callback if the HTTP request to Couch fails
function handleError(err) {
state = DISCONNECTED;
}
// Callback if the HTTP request succeeds. CouchDB may
// report an error, so check for that.
function handleResponse(response) {
var data = response.data;
state = data.error ? SEMICONNECTED : CONNECTED;
}
// Try to connect to CouchDB, and update the indicator.
function updateIndicator() {
$http.get(PATH).then(handleResponse, handleError);
}
// Update the indicator initially, and start polling.
updateIndicator();
$interval(updateIndicator, INTERVAL);
return {
/**
* Get the glyph (single character used as an icon)
* to display in this indicator. This will return "D",
* which should appear as a database icon.
* @returns {string} the character of the database icon
*/
getGlyph: function () {
return "D";
},
/**
* Get the name of the CSS class to apply to the glyph.
* This is used to color the glyph to match its
* state (one of ok, caution or err)
* @returns {string} the CSS class to apply to this glyph
*/
getGlyphClass: function () {
return state.glyphClass;
},
/**
* Get the text that should appear in the indicator.
* @returns {string} brief summary of connection status
*/
getText: function () {
return state.text;
},
/**
* Get a longer-form description of the current connection
* space, suitable for display in a tooltip
* @returns {string} longer summary of connection status
*/
getDescription: function () {
return state.description;
}
};
}
return CouchIndicator;
}
);

View File

@ -0,0 +1,162 @@
/*global define*/
define(
["./CouchDocument"],
function (CouchDocument) {
'use strict';
// JSLint doesn't like dangling _'s, but CouchDB uses these, so
// hide this behind variables.
var REV = "_rev",
ID = "_id";
/**
* The CouchPersistenceProvider reads and writes JSON documents
* (more specifically, domain object models) to/from a CouchDB
* instance.
* @constructor
*/
function ElasticPersistenceProvider($http, $q, SPACE, PATH) {
var spaces = [ SPACE ],
revs = {};
// Convert a subpath to a full path, suitable to pass
// to $http.
function url(subpath) {
return PATH + '/' + subpath;
}
// Issue a request using $http; get back the plain JS object
// from the expected JSON response
function request(subpath, method, value) {
return $http({
method: method,
url: url(subpath),
data: value
}).then(function (response) {
return response.data;
}, function () {
return undefined;
});
}
// Shorthand methods for GET/PUT methods
function get(subpath) {
return request(subpath, "GET");
}
function put(subpath, value) {
return request(subpath, "PUT", value);
}
// Pull out a list of document IDs from CouchDB's
// _all_docs response
function getIdsFromAllDocs(allDocs) {
return allDocs.rows.map(function (r) { return r.id; });
}
// Get a domain object model out of CouchDB's response
function getModel(response) {
if (response && response.model) {
revs[response[ID]] = response[REV];
return response.model;
} else {
return undefined;
}
}
// Check the response to a create/update/delete request;
// track the rev if it's valid, otherwise return false to
// indicate that the request failed.
function checkResponse(response) {
if (response && response.ok) {
revs[response.id] = response.rev;
return response.ok;
} else {
return false;
}
}
return {
/**
* List all persistence spaces which this provider
* recognizes.
*
* @returns {Promise.<string[]>} a promise for a list of
* spaces supported by this provider
*/
listSpaces: function () {
return $q.when(spaces);
},
/**
* List all objects (by their identifiers) that are stored
* in the given persistence space, per this provider.
* @param {string} space the space to check
* @returns {Promise.<string[]>} a promise for the list of
* identifiers
*/
listObjects: function (space) {
return get("_all_docs").then(getIdsFromAllDocs);
},
/**
* Create a new object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
createObject: function (space, key, value) {
return put(key, new CouchDocument(key, value))
.then(checkResponse);
},
/**
* Read an existing object back from persistence.
* @param {string} space the space in which to look for
* the object
* @param {string} key the identifier for the persisted object
* @returns {Promise.<object>} a promise for the stored
* object; this will resolve to undefined if no such
* object is found.
*/
readObject: function (space, key) {
return get(key).then(getModel);
},
/**
* Update an existing object in the specified persistence space.
* @param {string} space the space in which to store the object
* @param {string} key the identifier for the persisted object
* @param {object} value a JSONifiable object that should be
* stored and associated with the provided identifier
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
updateObject: function (space, key, value) {
return put(key, new CouchDocument(key, value, revs[key]))
.then(checkResponse);
},
/**
* Delete an object in the specified persistence space.
* @param {string} space the space from which to delete this
* object
* @param {string} key the identifier of the persisted object
* @param {object} value a JSONifiable object that should be
* deleted
* @returns {Promise.<boolean>} a promise for an indication
* of the success (true) or failure (false) of this
* operation
*/
deleteObject: function (space, key, value) {
return put(key, new CouchDocument(key, value, revs[key], true))
.then(checkResponse);
}
};
}
return ElasticPersistenceProvider;
}
);

View File

@ -0,0 +1,44 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
/**
* DomainObjectProviderSpec. Created by vwoeltje on 11/6/14.
*/
define(
["../src/CouchDocument"],
function (CouchDocument) {
"use strict";
// JSLint doesn't like dangling _'s, but CouchDB uses these, so
// hide this behind variables.
var REV = "_rev",
ID = "_id",
DELETED = "_deleted";
describe("A couch document", function () {
it("includes an id", function () {
expect(new CouchDocument("testId", {})[ID])
.toEqual("testId");
});
it("includes a rev only when one is provided", function () {
expect(new CouchDocument("testId", {})[REV])
.not.toBeDefined();
expect(new CouchDocument("testId", {}, "testRev")[REV])
.toEqual("testRev");
});
it("includes the provided model", function () {
var model = { someKey: "some value" };
expect(new CouchDocument("testId", model).model)
.toEqual(model);
});
it("marks documents as deleted only on request", function () {
expect(new CouchDocument("testId", {}, "testRev")[DELETED])
.not.toBeDefined();
expect(new CouchDocument("testId", {}, "testRev", true)[DELETED])
.toBe(true);
});
});
}
);

View File

@ -0,0 +1,111 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
define(
["../src/CouchIndicator"],
function (CouchIndicator) {
"use strict";
describe("The CouchDB status indicator", function () {
var mockHttp,
mockInterval,
testPath,
testInterval,
mockPromise,
indicator;
beforeEach(function () {
mockHttp = jasmine.createSpyObj("$http", [ "get" ]);
mockInterval = jasmine.createSpy("$interval");
mockPromise = jasmine.createSpyObj("promise", [ "then" ]);
testPath = "/test/path";
testInterval = 12321; // Some number
mockHttp.get.andReturn(mockPromise);
indicator = new CouchIndicator(
mockHttp,
mockInterval,
testPath,
testInterval
);
});
it("polls for changes", function () {
expect(mockInterval).toHaveBeenCalledWith(
jasmine.any(Function),
testInterval
);
});
it("has a database icon", function () {
expect(indicator.getGlyph()).toEqual("D");
});
it("consults the database at the configured path", function () {
expect(mockHttp.get).toHaveBeenCalledWith(testPath);
});
it("changes when the database connection is nominal", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[0]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("ok");
});
it("changes when the server reports an error", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, with
// an error field.
mockPromise.then.mostRecentCall.args[0](
{ data: { error: "Uh oh." } }
);
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("caution");
});
it("changes when the server cannot be reached", function () {
var initialText = indicator.getText(),
initialDescrption = indicator.getDescription(),
initialGlyphClass = indicator.getGlyphClass();
// Nominal just means getting back an objeect, without
// an error field.
mockPromise.then.mostRecentCall.args[1]({ data: {} });
// Verify that these values changed;
// don't test for specific text.
expect(indicator.getText()).not.toEqual(initialText);
expect(indicator.getGlyphClass()).not.toEqual(initialGlyphClass);
expect(indicator.getDescription()).not.toEqual(initialDescrption);
// Do check for specific class
expect(indicator.getGlyphClass()).toEqual("err");
});
});
}
);

View File

@ -0,0 +1,171 @@
/*global define,Promise,describe,it,expect,beforeEach,waitsFor,jasmine*/
/**
* DomainObjectProviderSpec. Created by vwoeltje on 11/6/14.
*/
define(
["../src/CouchPersistenceProvider"],
function (CouchPersistenceProvider) {
"use strict";
describe("The couch persistence provider", function () {
var mockHttp,
mockQ,
testSpace = "testSpace",
testPath = "/test/db",
capture,
provider;
function mockPromise(value) {
return {
then: function (callback) {
return mockPromise(callback(value));
}
};
}
beforeEach(function () {
mockHttp = jasmine.createSpy("$http");
mockQ = jasmine.createSpyObj("$q", ["when"]);
mockQ.when.andCallFake(mockPromise);
// Capture promise results
capture = jasmine.createSpy("capture");
provider = new CouchPersistenceProvider(
mockHttp,
mockQ,
testSpace,
testPath
);
});
it("reports available spaces", function () {
provider.listSpaces().then(capture);
expect(capture).toHaveBeenCalledWith([testSpace]);
});
// General pattern of tests below is to simulate CouchDB's
// response, verify that request looks like what CouchDB
// would expect, and finally verify that CouchPersistenceProvider's
// return values match what is expected.
it("lists all available documents", function () {
mockHttp.andReturn(mockPromise({
data: { rows: [ { id: "a" }, { id: "b" }, { id: "c" } ] }
}));
provider.listObjects().then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/_all_docs", // couch document listing
method: "GET"
});
expect(capture).toHaveBeenCalledWith(["a", "b", "c"]);
});
it("allows object creation", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "xyz", "ok": true }
}));
provider.createObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
data: {
"_id": "abc",
metadata: jasmine.any(Object),
model: model
}
});
expect(capture).toHaveBeenCalledWith(true);
});
it("allows object models to be read back", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "xyz", "model": model }
}));
provider.readObject("testSpace", "abc").then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "GET"
});
expect(capture).toHaveBeenCalledWith(model);
});
it("allows object update", function () {
var model = { someKey: "some value" };
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "xyz", "model": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "uvw", "ok": true }
}));
provider.updateObject("testSpace", "abc", model).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
data: {
"_id": "abc",
"_rev": "xyz",
metadata: jasmine.any(Object),
model: model
}
});
expect(capture).toHaveBeenCalledWith(true);
});
it("allows object deletion", function () {
// First do a read to populate rev tags...
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "xyz", "model": {} }
}));
provider.readObject("testSpace", "abc");
// Now perform an update
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "uvw", "ok": true }
}));
provider.deleteObject("testSpace", "abc", {}).then(capture);
expect(mockHttp).toHaveBeenCalledWith({
url: "/test/db/abc",
method: "PUT",
data: {
"_id": "abc",
"_rev": "xyz",
"_deleted": true,
metadata: jasmine.any(Object),
model: {}
}
});
expect(capture).toHaveBeenCalledWith(true);
});
it("reports failure to create objects", function () {
var model = { someKey: "some value" };
mockHttp.andReturn(mockPromise({
data: { "_id": "abc", "_rev": "xyz", "ok": false }
}));
provider.createObject("testSpace", "abc", model).then(capture);
expect(capture).toHaveBeenCalledWith(false);
});
it("returns undefined when objects are not found", function () {
// Act like a 404
mockHttp.andReturn({
then: function (success, fail) {
return mockPromise(fail());
}
});
provider.readObject("testSpace", "abc").then(capture);
expect(capture).toHaveBeenCalledWith(undefined);
});
});
}
);

View File

@ -0,0 +1,5 @@
[
"CouchDocument",
"CouchIndicator",
"CouchPersistenceProvider"
]