@windingtree/wt-js-libs
Version:
Javascript libraries to interact with the Winding Tree contracts
395 lines (378 loc) • 14.8 kB
JavaScript
import cloneDeep from 'lodash.clonedeep';
// TODO replace this with a runtime passed implementation of an interface
import OffChainDataClient from '../off-chain-data-client';
import { StoragePointerError } from './errors';
/**
* `StoragePointer` serves as a representation of an
* off-chain document holding JSON data. This generic class
* does not enforce any protocol/schema and contains
* infrastructure code that helps to set up field definition
* and getters for every data field. It does not provide any
* means of writing data, its sole purpose is for reading.
*
* It is possible to use this recursively, so you can define
* a field as another storage pointer. The configuration is
* declarative and may look like this:
*
* ```
* const pointer = StoragePointer.createInstance('some://url');
* pointer.ref; // contains 'some://url',
* contents = await pointer.contents;
* contents.name;
* contents.description;
* // etc.
* ```
*
* Or in recursive cases:
*
* ```
* const pointer = StoragePointer.createInstance('some://url', {
* description: {
* required: false,
* },
* });
* pointer.ref; // contains 'some://url'
* contents = await pointer.contents;
* contents.signature;
* const descPointer = contents.description;
* descPointer.ref; // contains whatever is written in a description property in a document located on 'some://url'
* descContents = await descPointer.contents; // data from descPointer.ref
* ```
* so if you mark a storage pointer as not required, the library will not crash if the field
* is missing or nulled.
*
* Only subordinate storage pointers (`children`) have to be defined beforehand, so the `signature`
* field above may contain a complex JSON object.
*
* Recursion is supported, if described in `children` definition.
* See [test](https://github.com/windingtree/wt-js-libs/blob/8fdfe3aed7248fd327b60f1a56f0d3a3b1d3e93b/test/wt-libs/storage-pointer.spec.js#L448) for a working example.
* ```
* const innerUri = InMemoryAdapter.storageInstance.create({
* data: 'wt',
* });
* const outerUri = InMemoryAdapter.storageInstance.create({
* detail: `in-memory://${innerUri}`,
* bar: 'foo',
* });
* const pointer = StoragePointer.createInstance(`in-memory://${outerUri}`, {
* detail: {
* children: {},
* },
* });
* pointer.ref; // contains outerUri
* let contents = await pointer.contents;
* contents.bar; // contains 'foo'
* contents.detail.ref; // contains innerUri
* (await contents.detail.contents).data; // contains 'wt'. See `toPlainObject` if you want to avoid multiple `await` clauses.
* ```
*
* StoragePointers in arrays are also supported, see [an example](https://github.com/windingtree/wt-js-libs/blob/8fdfe3aed7248fd327b60f1a56f0d3a3b1d3e93b/test/wt-libs/storage-pointer.spec.js#L427).
* Note that arrays are not supported for `nested` children types.
*/
export class StoragePointer {
/**
* Returns a new instance of StoragePointer.
*
* Normalizes the `fields` format before creating the actual
* instance
*
* @param {string} uri where to look for data document. It has to include schema, i. e. `https://example.com/data`
* @param {ChildrenType} children subordinate storage pointers
* @throw {StoragePointerError} if uri is not defined
*/
static createInstance (uri, children) {
if (!uri) {
throw new StoragePointerError('Cannot instantiate StoragePointer without uri');
}
const uniqueFields = {};
children = children || {};
for (const fieldName in children) {
if (uniqueFields[fieldName.toLowerCase()]) {
throw new StoragePointerError('Cannot create instance: Conflict in field names.');
}
if (children[fieldName].required === undefined) {
children[fieldName].required = true;
}
uniqueFields[fieldName.toLowerCase()] = 1;
}
return new StoragePointer(uri, children);
}
/**
* Detects schema from the uri, based on that instantiates an appropriate
* `OffChainDataAdapterInterface` implementation and sets up all data
* getters.
*
* @param {string} uri where to look for the data
* @param {ChildrenType} children subordinate storage pointers
*/
constructor (uri, children) {
this.ref = uri;
this._downloaded = false;
this._data = {};
this._children = children || [];
}
get contents () {
return (async () => {
if (!this._downloaded) {
await this._downloadFromStorage();
}
return this._data;
})();
}
/**
* Reset the storage pointer, thus forcing it to lazily
* download the data again.
*
* Usable when the the off-chain data might have changed since
* the last query and the most recent version of it is needed.
*/
async reset () {
// If the download is still in progress, wait for it to
// finish to reduce race condition possibilities.
await (this._downloading || Promise.resolve());
// Force repeated download upon the next contents access.
delete this._downloading;
this._downloaded = false;
}
/**
* Detects schema from an uri, i. e.
* from `schema://some-data`, detects `schema`.
*/
_detectSchema (uri) {
const matchResult = uri.match(/([a-zA-Z-]+):\/\//i);
return matchResult ? matchResult[1] : null;
}
/**
* Returns appropriate implementation of `OffChainDataAdapterInterface`
* based on schema. Uses `OffChainDataClient.getAdapter` factory method.
*/
_getOffChainDataClient () {
if (!this._adapter) {
this._adapter = OffChainDataClient.getAdapter(this._detectSchema(this.ref));
}
return this._adapter;
}
/**
* Sets the internal _data property based on the data retrieved from
* the storage.
*/
_initFromStorage (data) {
this._data = cloneDeep(data); // Copy data to avoid issues with mutability.
for (const fieldName in this._children) {
const fieldData = this._data[fieldName],
fieldDef = this._children[fieldName],
expectedType = fieldDef.nested ? 'object' : 'string';
if (fieldDef.required && !fieldData) {
throw new StoragePointerError(`Cannot access field '${fieldName}' which is required.`);
}
if (!fieldData) {
continue;
}
if (!Array.isArray(fieldData) && typeof fieldData !== expectedType) { // eslint-disable-line valid-typeof
const value = fieldData ? fieldData.toString() : 'undefined';
throw new StoragePointerError(`Cannot access field '${fieldName}' on '${value}' which does not appear to be of type ${expectedType} but ${typeof fieldData}.`);
}
if (fieldDef.nested) {
if (Array.isArray(fieldData)) {
throw new StoragePointerError(`Cannot access field '${fieldName}'. Nested pointer cannot be an Array.`);
} else {
const pointers = {};
for (const key of Object.keys(fieldData)) {
if (typeof fieldData[key] !== 'string') {
throw new StoragePointerError(`Cannot access field '${fieldName}.${key}' which does not appear to be of type string.`);
}
pointers[key] = StoragePointer.createInstance(fieldData[key], fieldDef.children || {});
}
this._data[fieldName] = pointers;
}
} else {
if (Array.isArray(fieldData)) {
this._data[fieldName] = [];
for (let i = 0; i < fieldData.length; i++) {
this._data[fieldName].push(fieldData[i]);
for (const refName in fieldDef.children) {
if (!fieldData[i][refName].ref || !fieldData[i][refName].contents) {
this._data[fieldName][i][refName] = StoragePointer.createInstance(fieldData[i][refName], fieldDef.children[refName].children);
}
}
}
} else {
this._data[fieldName] = StoragePointer.createInstance(fieldData, fieldDef.children || {});
}
}
}
}
/**
* Gets the data document via `OffChainDataAdapterInterface`
* and uses it to initialize the internal state.
*/
async _downloadFromStorage () {
if (!this._downloading) {
this._downloading = (async () => {
const adapter = this._getOffChainDataClient();
try {
const data = await adapter.download(this.ref);
this._initFromStorage(data ? JSON.parse(data) : {});
this._downloaded = true;
} catch (err) {
if (err instanceof StoragePointerError) {
throw err;
}
throw new StoragePointerError('Cannot download data: ' + err.message, err);
}
})();
}
return this._downloading;
}
/**
* Gets the data document via `OffChainDataAdapterInterface.downloadRaw`.
* Does not interpret data, does not intialize internal state, does not cache.
* @return {string}
*/
async downloadRaw () {
const adapter = this._getOffChainDataClient();
try {
// await here to properly catch errors
const data = await adapter.download(this.ref);
return data;
} catch (err) {
if (err instanceof StoragePointerError) {
throw err;
}
throw new StoragePointerError('Cannot download data: ' + err.message, err);
}
}
/**
* Recursively transforms the off chain stored document to a sync plain
* javascript object. By default, traverses the whole document tree.
*
* You can limit which branches will get downloaded by providing a `resolvedFields`
* argument which acccepts a list of paths in dot notation (`father.son.child`).
* Every child will then get resolved recursively.
*
* If you don't want some paths to get downloaded, just provide at least one sibling
* field on that level which is not a `StoragePointer`. An empty list means no fields
* will be resolved.
*
* Data always gets downloaded if this method is called.
*
* The resulting structure mimicks the original `StoragePointer` data structure:
*
* ```
* {
* 'ref': 'schema://url',
* 'contents': {
* 'field': 'value',
* 'storagePointer': {
* 'ref': 'schema://originalUri',
* 'contents': {
* 'field': 'value'
* }
* },
* 'storagePointers': [ // pointers in arrays are resolved as well
* {
* 'ref': 'schema://originalUri1',
* 'contents': {
* 'field': 'value1'
* }
* },{
* 'ref': 'schema://originalUri2',
* 'contents': {
* 'field': 'value2'
* }
* }
* ],
* 'unresolvedStoragePointer': 'schema://unresolved-url'
* }
* }
* ```
*
* @param {Array<string>} resolvedFields List of fields that limit the resulting dataset in dot notation (`father.child.son`).
* If an empty array is provided, no resolving is done. If the argument is missing, all fields are resolved.
* You don't need to specify path to a field in any special way when it is in an array (e.g. storagePointers.0.field or similar).
* Array items are resolved as if they're on the array level (i.e. storagePointers.field).
* @param {integer} depth Number of levels to resolve in case no `resolvedFields` are specified on a level anymore.
* Note that calling `toPlainObject` with specified fields may lead to fields being not specified in recursive calls
* (e.g. when calling `toPlainObject(['a.b'])` all fields in data.a.b will be resolved - unless limited by depth).
* @throws {StoragePointerError} when an adapter encounters an error while accessing the data
*/
async toPlainObject (resolvedFields, depth = 9999) {
// Download data
await this._downloadFromStorage();
let result = {};
// Prepare subtrees that will possibly be resolved later by splitting the dot notation.
const currentFieldDef = {};
if (resolvedFields) {
for (const field of resolvedFields) {
let currentLevelName, remainingPath;
if (field.indexOf('.') === -1) {
currentLevelName = field;
} else {
currentLevelName = field.substring(0, field.indexOf('.'));
remainingPath = field.substring(field.indexOf('.') + 1);
}
if (remainingPath) {
if (!currentFieldDef[currentLevelName]) {
currentFieldDef[currentLevelName] = [];
}
currentFieldDef[currentLevelName].push(remainingPath);
} else {
currentFieldDef[currentLevelName] = undefined;
}
}
}
// Put everything together
const contents = await this.contents;
if (Array.isArray(contents)) {
result = contents;
} else {
for (const fieldName in this._data) {
if (!this._children || !this._children[fieldName]) {
// Do not fabricate undefined fields if they are actually missing in the source data
// eslint-disable-next-line no-prototype-builtins
if (this._data && this._data.hasOwnProperty(fieldName)) {
result[fieldName] = contents[fieldName];
}
continue;
}
// Check if the user wants to resolve the child StoragePointer;
// eslint-disable-next-line no-prototype-builtins
const resolve = (!resolvedFields && depth > 0) || currentFieldDef.hasOwnProperty(fieldName),
nested = this._children && this._children[fieldName].nested;
if (nested) {
result[fieldName] = {};
for (const key of Object.keys(contents[fieldName])) {
if (resolve && depth > 1) {
result[fieldName][key] = await contents[fieldName][key].toPlainObject(currentFieldDef[fieldName], depth - 2);
} else {
result[fieldName][key] = contents[fieldName][key].ref;
}
}
} else {
if (resolve) {
if (Array.isArray(contents[fieldName])) {
result[fieldName] = [];
for (let i = 0; i < contents[fieldName].length; i++) {
result[fieldName].push(contents[fieldName][i]);
for (const key of Object.keys(contents[fieldName][i])) {
if (contents[fieldName][i][key].toPlainObject && depth > 1) {
result[fieldName][i][key] = await contents[fieldName][i][key].toPlainObject(currentFieldDef[fieldName], depth - 2);
}
}
}
} else {
result[fieldName] = await contents[fieldName].toPlainObject(currentFieldDef[fieldName], depth - 1);
}
} else {
result[fieldName] = this._data[fieldName].ref;
}
}
}
}
return {
ref: this.ref,
contents: result,
};
}
}
export default StoragePointer;