/**
* Copyright (C) 2017-2018 Regents of the University of California.
* @author: Jeff Thompson <[email protected]>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
* A copy of the GNU Lesser General Public License is in the file COPYING.
*/
/**
* FireflyFace extends Face to override expressInterest, registerPrefix and
* putData to interface with Google Firestore. In general, this converts each
* NDN name like "/ndn/user/bob" to the Firestore path "/ndn/_/user/_/bob/_"
* where each name component has a document named "_" and a collection having
* the name of its child component. This updates the "_" document with fields
* like "interestExpressTime" and "data" to simulate NDN messaging. (The "_"
* document also has a "children" field with a set of the names of the children
* nodes, which is necessary because Firestore doesn't allow enumerating
* children.)
* @param {firebase.firestore.Firestore} (optional) The Firestore object which
* is already created. If omitted, use the default "ndn-firefly" project.
*/
var FireflyFace = function FireflyFace(db)
{
// Call the base constructor.
// Make Face.reconnectAndExpressInterest call expressInterestHelper directly.
Face.call(new Transport(), { equals: function() { return true; } });
this.readyStatus = Face.OPENED;
if (db == undefined) {
var config = {
apiKey: "AIzaSyCDa5xAuQw78RwcpIDT0NmgmcJ9WVL60GY",
authDomain: "ndn-firefly.firebaseapp.com",
databaseURL: "https://ndn-firefly.firebaseio.com",
projectId: "ndn-firefly",
storageBucket: "",
messagingSenderId: "225388759140"
};
firebase.initializeApp(config);
db = firebase.firestore();
}
this.db_ = db;
// The set of Name URIs corresponding to the collections where we have added
// a snapshot listener to the "_" and "children" documents. The key is the URI
// string and the value is true.
this.listeningNameUris_ = {};
this.pendingInterestTable_ = new PendingInterestTable();
this.interestFilterTable_ = new InterestFilterTable();
};
FireflyFace.prototype = new Face(new Transport(), { equals: function() { return true; } });
FireflyFace.prototype.name = "FireflyFace";
/**
* Override to do the work of expressInterest using Firestore. If a data packet
* matching the interest is already in Firestore, call onData immediately,
* Otherwise, add onSnapshot listeners at toFirestorePath(interest.getName())
* and children to monitor for the addition of a "data" field.
*/
FireflyFace.prototype.expressInterestHelper = function
(pendingInterestId, interest, onData, onTimeout, onNetworkNack, wireFormat)
{
var thisFace = this;
// First check if the Data packet is already in Firestore.
// TODO: Check MustBeFresh.
this.getMatchingDataPromise_(interest.getName(), interest.getMustBeFresh())
.then(function(data) {
if (data != null) {
// Answer onData immediately.
onData(interest, data);
return SyncPromise.resolve();
}
else {
if (thisFace.pendingInterestTable_.add
(pendingInterestId, interest, onData, onTimeout, onNetworkNack) == null)
// removePendingInterest was already called with the pendingInterestId.
return SyncPromise.resolve();
// Express an interest in Firestore.
return thisFace.establishDocumentPromise_(interest.getName())
.then(function(document) {
// TODO: Monitor sub collections with a longer name.
document.onSnapshot(function(document) {
// TODO: Check MustBeFresh.
if (document.data().data) {
var data = new Data();
data.wireDecode(new Blob(document.data().data.toUint8Array(), false));
// Imitate Face.onReceivedElement.
var pendingInterests = [];
thisFace.pendingInterestTable_.extractEntriesForExpressedInterest
(data, pendingInterests);
// Process each matching PIT entry (if any).
for (var i = 0; i < pendingInterests.length; ++i) {
var pendingInterest = pendingInterests[i];
try {
pendingInterest.getOnData()(pendingInterest.getInterest(), data);
} catch (ex) {
console.log("Error in onData: " + NdnCommon.getErrorWithStackTrace(ex));
}
}
}
});
// TODO: Check if an existing interestLifetime has a later expiration.
return document.set({
interestExpressTime: firebase.firestore.FieldValue.serverTimestamp(),
interestLifetime: interest.getInterestLifetimeMilliseconds()
}, { merge: true });
});
}
}).catch(function(error) {
console.log("Error in expressInterest:", error);
});
};
/**
* Override to do the work of registerPrefix using Firestore. Add onSnapshot
* listeners at toFirestorePath(prefix.getName()) and children to monitor for
* the addition of an "interestExpressTime" field. See addListeners_ for details.
*/
FireflyFace.prototype.nfdRegisterPrefix = function
(registeredPrefixId, prefix, onInterest, flags, onRegisterFailed,
onRegisterSuccess, commandKeyChain, commandCertificateName, wireFormat)
{
var thisFace = this;
this.establishDocumentPromise_(prefix)
.then(function(document) {
// Monitor this and all sub documents.
// Imitate Face.RegisterResponse.onData .
var interestFilterId = 0;
if (onInterest != null)
// registerPrefix was called with the "combined" form that includes the
// callback, so add an InterestFilterEntry.
interestFilterId = thisFace.setInterestFilter
(new InterestFilter(prefix), onInterest);
if (!thisFace.registeredPrefixTable_.add
(registeredPrefixId, prefix, interestFilterId)) {
// removeRegisteredPrefix was already called with the registeredPrefixId.
if (interestFilterId > 0)
// Remove the related interest filter we just added.
this.parent.unsetInterestFilter(interestFilterId);
}
else {
// TODO: Check onRegisterSuccess.
thisFace.addListeners_(prefix.toUri(), document.parent);
}
});
};
/**
* The OnInterest callback calls this to put a Data packet which satisfies an
* Interest. Override to put the Data packet into Firestore.
* @param {Data} data The Data packet which satisfies the interest.
* @param {WireFormat} wireFormat (optional) A WireFormat object used to encode
* the Data packet. If omitted, use WireFormat.getDefaultWireFormat().
* @throws Error If the encoded Data packet size exceeds getMaxNdnPacketSize().
*/
FireflyFace.prototype.putData = function(data, wireFormat)
{
wireFormat = (wireFormat || WireFormat.getDefaultWireFormat());
var encoding = data.wireEncode(wireFormat);
if (encoding.size() > Face.getMaxNdnPacketSize())
throw new Error
("The encoded Data packet size exceeds the maximum limit getMaxNdnPacketSize()");
// TODO: Check if we can remove Interest fields in Firestore.
this.setDataPromise_(data)
.catch(function(error) {
console.log("Error in putData:", error);
});
};
/**
* Look in Firestore for an existing Data packet which matches the name.
* @param {Name} name The Name.
* @param {boolean} mustBeFresh If true, make sure the Data is not expired
* according to the Firestore document "storeTime" and "freshnessPeriod".
* @returns {Promise} A promise that returns the matching Data, or returns null
* if not found.
*/
FireflyFace.prototype.getMatchingDataPromise_ = function(name, mustBeFresh)
{
// TODO: Check mustBeFresh.
// TODO: Do longest prefix match.
return this.db_.doc(FireflyFace.toFirestorePath(name)).get()
.then(function(document) {
if (document.exists && document.data().data) {
var data = new Data();
// TODO: Check for decoding error.
data.wireDecode(new Blob(document.data().data.toUint8Array(), false));
return SyncPromise.resolve(data);
}
else
return SyncPromise.resolve(null);
});
};
/**
* Get the Firestore path for the Name, for example "/ndn/_/user/_/bob/_" for
* the NDN name "/ndn/user/bob".
* @param {Name} name The Name.
* @returns {string} The Firestore path.
*/
FireflyFace.toFirestorePath = function(name)
{
var result = "";
for (var i = 0; i < name.size(); ++i)
result += "/"+ name.components[i].toEscapedString() + "/_";
return result;
};
/**
* Recursively add an onSnapshot listener at collection.doc("_") and all
* children. When collection.doc("_") is updated with an "interestExpressTime"
* field, onSnapshot processes as if we have received an interest and calls
* OnInterest callbacks. Add nameUri to listeningNameUris_. However, if nameUri
* is already in listeningNameUris_, do nothing.
* @param {string} nameUri The URI of the name represented by collection.
* @param {firebase.firestore.DocumentReference} collection The collection with
* name nameUri.
*/
FireflyFace.prototype.addListeners_ = function(nameUri, collection)
{
if (this.listeningNameUris_[nameUri])
// We are already listening.
return;
this.listeningNameUris_[nameUri] = true;
var thisFace = this;
collection.doc("_").onSnapshot(function(document) {
if (!document.exists)
return;
// TODO: Listen for added Data.
// TODO: A better check if there is already a matching Data.
if (document.data().interestExpressTime && !document.data().data) {
var interestLifetime = document.data().interestLifetime;
// TODO: Check interestLifetime for an expired interest.
var interest = new Interest(new Name(nameUri));
interest.setInterestLifetimeMilliseconds(interestLifetime);
// Imitate Face.onReceivedElement.
// Call all interest filter callbacks which match.
var matchedFilters = [];
thisFace.interestFilterTable_.getMatchedFilters(interest, matchedFilters);
for (var i = 0; i < matchedFilters.length; ++i) {
var entry = matchedFilters[i];
try {
entry.getOnInterest()
(entry.getFilter().getPrefix(), interest, thisFace,
entry.getInterestFilterId(), entry.getFilter());
} catch (ex) {
console.log("Error in onInterest: " + NdnCommon.getErrorWithStackTrace(ex));
}
}
}
});
collection.doc("children").onSnapshot(function(document) {
if (!document.exists)
return;
for (var componentUri in document.data())
// This will call onSnapshot and recursively add children.
thisFace.addListeners_
(nameUri + "/" + componentUri,
collection.doc("_").collection(componentUri));
});
};
/**
* Set the "data", "storeTime" and "freshnessPeriod" fields in the Firestore
* document based on data.getName(). If the freshnessPeriod is not specified,
* this sets it to null. This replaces existing fields.
* @param {Data} data The Data packet.
* @param {WireFormat} wireFormat A WireFormat object used to encode the Data
* packet.
* @return {Promise} A promise that fulfills when the operation is complete.
*/
FireflyFace.prototype.setDataPromise_ = function(data, wireFormat)
{
return this.establishDocumentPromise_(data.getName())
.then(function(document) {
return document.set({
data: firebase.firestore.Blob.fromBase64String
(data.wireEncode(wireFormat).buf().toString('base64')),
storeTime: firebase.firestore.FieldValue.serverTimestamp(),
freshnessPeriod: data.getMetaInfo().getFreshnessPeriod()
}, { merge: true });
});
};
/**
* Get the Firestore document for the given name, creating the "children"
* documents at each level in the collection tree as needed. For example, if
* Firestore has the document /ndn/_/user/_/joe/_ and you ask for the document
* for the name /ndn/role/doctor this returns the document
* /ndn/_/role/_/doctor/_ and adds { role: null } to /ndn/children and adds
* { doctor: null } to /ndn/_/role/children . (We represent the set of children
* by an object with the set elements are the key and the value is null.)
* @param {Name} name The Name for the document.
* @returns {Promise} A promise that returns the
* firebase.firestore.DocumentReference .
*/
FireflyFace.prototype.establishDocumentPromise_ = function(name)
{
// Update the "children" document of the collection, and recursively call this
// with each component until the collection for the final component and return
// its "_" document.
var establish = function(collection, iComponent) {
if (iComponent >= name.size() - 1)
// We're finished.
return SyncPromise.resolve(collection.doc("_"));
else {
var childString = name.get(iComponent + 1).toEscapedString();
// Update the "children" document.
var content = {};
content[childString] = null;
return collection.doc("children").set(content, { merge: true })
.then(function () {
return establish(collection.doc("_").collection(childString), iComponent + 1);
});
}
};
return establish(this.db_.collection(name.get(0).toEscapedString()), 0);
};