remotestorage/remotestorage.js

View on GitHub
src/wireclient.ts

Summary

Maintainability
C
1 day
Test Coverage
/**
 * This file exposes a get/put/delete interface on top of fetch() or XMLHttpRequest.
 * It requires to be configured with parameters about the remotestorage server to
 * connect to.
 * Each instance of WireClient is always associated with a single remotestorage
 * server and access token.
 *
 * Usually the WireClient instance can be accessed via `remoteStorage.remote`.
 *
 * This is the get/put/delete interface:
 *
 *   - #get() takes a path and optionally a ifNoneMatch option carrying a version
 *     string to check. It returns a promise that will be fulfilled with the HTTP
 *     response status, the response body, the MIME type as returned in the
 *     'Content-Type' header and the current revision, as returned in the 'ETag'
 *     header.
 *   - #put() takes a path, the request body and a content type string. It also
 *     accepts the ifMatch and ifNoneMatch options, that map to the If-Match and
 *     If-None-Match headers respectively. See the remotestorage-01 specification
 *     for details on handling these headers. It returns a promise, fulfilled with
 *     the same values as the one for #get().
 *   - #delete() takes a path and the ifMatch option as well. It returns a promise
 *     fulfilled with the same values as the one for #get().
 *
 * In addition to this, the WireClient has some compatibility features to work with
 * remotestorage 2012.04 compatible storages. For example it will cache revisions
 * from folder listings in-memory and return them accordingly as the "revision"
 * parameter in response to #get() requests. Similarly it will return 404 when it
 * receives an empty folder listing, to mimic remotestorage-01 behavior. Note
 * that it is not always possible to know the revision beforehand, hence it may
 * be undefined at times (especially for caching-roots).
 */
import RemoteStorage from './remotestorage';
import Authorize from './authorize';
import EventHandling from './eventhandling';
import UnauthorizedError from './unauthorized-error';
import log from './log';
import {
  applyMixins,
  cleanPath,
  getJSONFromLocalStorage,
  getTextFromArrayBuffer,
  isFolder,
  localStorageAvailable,
  shouldBeTreatedAsBinary
} from './util';
import {requestWithTimeout, isArrayBufferView} from "./requests";
import {Remote, RemoteBase, RemoteResponse, RemoteSettings} from "./remote";

let hasLocalStorage;
const SETTINGS_KEY = 'remotestorage:wireclient';

const API_2012 = 1;
const API_00 = 2;
const API_01 = 3;
const API_02 = 4;
const API_HEAD = 5;

const STORAGE_APIS = {
  'draft-dejong-remotestorage-00': API_00,
  'draft-dejong-remotestorage-01': API_01,
  'draft-dejong-remotestorage-02': API_02,
  'https://www.w3.org/community/rww/wiki/read-write-web-00#simple': API_2012
};

function readSettings () {
  const settings = getJSONFromLocalStorage(SETTINGS_KEY) || {};
  const { userAddress, href, storageApi, token, properties } = settings;

  return { userAddress, href, storageApi, token, properties };
}

function determineCharset (mimeType: string): string {
  let charset = 'utf-8';
  let charsetMatch;

  if (mimeType) {
    charsetMatch = mimeType.match(/charset=(.+)$/);
    if (charsetMatch) {
      charset = charsetMatch[1];
    }
  }
  return charset;
}

function isFolderDescription (body: object): boolean {
  return ((body['@context'] === 'http://remotestorage.io/spec/folder-description')
    && (typeof (body['items']) === 'object'));
}

function isSuccessStatus (status: number): boolean {
  return [201, 204, 304].indexOf(status) >= 0;
}

function isErrorStatus (status: number): boolean {
  return [401, 403, 404, 412].indexOf(status) >= 0;
}

class WireClient extends RemoteBase implements Remote {
  /**
   * Holds the bearer token of this WireClient, as obtained in the OAuth dance
   *
   * Example:
   *   (start code)
   *
   *   remoteStorage.remote.token
   *   // -> 'DEADBEEF01=='
   */
  token: string | false;

  /**
   * Holds the server's base URL, as obtained in the Webfinger discovery
   *
   * Example:
   *   (start code)
   *
   *   remoteStorage.remote.href
   *   // -> 'https://storage.example.com/users/jblogg/'
   */
  href: string;

  supportsRevs: boolean;

  _revisionCache: { [key: string]: any } = {};

  properties: object;

  constructor (rs: RemoteStorage) {
    super(rs);
    hasLocalStorage = localStorageAvailable();

    /**
     * Event: connected
     *   Fired when the wireclient connect method realizes that it is in
     *   possession of a token and href
     **/
    this.addEvents(['connected', 'not-connected']);

    if (hasLocalStorage) {
      const settings = readSettings();
      if (settings) {
        setTimeout(() => {
          this.configure(settings);
        }, 0);
      }
    }

    if (this.connected) {
      setTimeout(this._emit.bind(this), 0, 'connected');
    }
  }

  get storageType () {
    if (this.storageApi) {
      const spec = this.storageApi.match(/draft-dejong-(remotestorage-\d\d)/);
      return spec ? spec[1] : '2012.04';
    } else {
      return undefined;
    }
  }

  async _request (method: string, uri: string, token: string | false, headers: HeadersInit, body: XMLHttpRequestBodyInit, getEtag: boolean, fakeRevision?: string): Promise<RemoteResponse> {
    if (this.isForbiddenRequestMethod(method, uri)) {
      return Promise.reject(`Don't use ${method} on directories!`);
    }

    let revision: string | undefined;

    if (token !== Authorize.IMPLIED_FAKE_TOKEN) {
      headers['Authorization'] = 'Bearer ' + token;
    }

    this.rs._emit('wire-busy', {
      method: method,
      isFolder: isFolder(uri)
    });

    return requestWithTimeout(method, uri, {
      body: body,
      headers: headers,
      responseType: 'arraybuffer'
    }).then((response: XMLHttpRequest): Promise<RemoteResponse> => {
      if (!this.online) {
        this.online = true;
        this.rs._emit('network-online');
      }
      this.rs._emit('wire-done', {
        method: method,
        isFolder: isFolder(uri),
        success: true
      });

      if (isErrorStatus(response.status)) {
        log('[WireClient] Error response status', response.status);
        if (getEtag) {
          revision = this.stripQuotes(response.getResponseHeader('ETag'));
        } else {
          revision = undefined;
        }

        if (response.status === 401) {
          this.rs._emit('error', new UnauthorizedError());
        }

        return Promise.resolve({statusCode: response.status, revision: revision});
      } else if (isSuccessStatus(response.status) ||
        (response.status === 200 && method !== 'GET')) {
        revision = this.stripQuotes(response.getResponseHeader('ETag'));
        log('[WireClient] Successful request', revision);
        return Promise.resolve({statusCode: response.status, revision: revision});
      } else {
        const mimeType = response.getResponseHeader('Content-Type');
        if (getEtag) {
          revision = this.stripQuotes(response.getResponseHeader('ETag'));
        } else {
          revision = (response.status === 200) ? fakeRevision : undefined;
        }

        const charset = determineCharset(mimeType);

        if (shouldBeTreatedAsBinary(response.response, mimeType)) {
          log('[WireClient] Successful request with unknown or binary mime-type', revision);
          return Promise.resolve({
            statusCode: response.status,
            body: response.response,
            contentType: mimeType,
            revision: revision
          });
        } else {
          return getTextFromArrayBuffer(response.response, charset)
            .then((textContent) => {
              log('[WireClient] Successful request', revision);
              return Promise.resolve({
                statusCode: response.status,
                body: textContent,
                contentType: mimeType,
                revision: revision
              });
            });
        }
      }
    }, error => {
      if (this.online) {
        this.online = false;
        this.rs._emit('network-offline');
      }
      this.rs._emit('wire-done', {
        method: method,
        isFolder: isFolder(uri),
        success: false
      });

      return Promise.reject(error);
    });
  }

  /**
   * Sets the userAddress, href, storageApi, token, and properties of a
   * remote store. Also sets connected and online to true and emits the
   * 'connected' event, if both token and href are present.
   *
   * Parameters:
   *   settings - An object that may contain userAddress (string or null),
   *              href (string or null), storageApi (string or null), token (string
   *              or null), and/or properties (the JSON-parsed properties object
   *              from the user's WebFinger record, see section 10 of
   *              http://tools.ietf.org/html/draft-dejong-remotestorage-03
   *              or null).
   *              Fields that are not included (i.e. `undefined`), stay at
   *              their current value. To set a field, include that field
   *              with a `string` value. To reset a field, for instance when
   *              the user disconnected their storage, or you found that the
   *              token you have has expired, simply set that field to `null`.
   */
  configure (settings: RemoteSettings): void {
    if (typeof settings !== 'object') {
      throw new Error('WireClient configure settings parameter should be an object');
    }
    if (typeof settings.userAddress !== 'undefined') {
      this.userAddress = settings.userAddress;
    }
    if (typeof settings.href !== 'undefined') {
      this.href = settings.href;
    }
    if (typeof settings.storageApi !== 'undefined') {
      this.storageApi = settings.storageApi;
    }
    if (typeof settings.token !== 'undefined') {
      this.token = settings.token;
    }
    if (typeof settings.properties !== 'undefined') {
      this.properties = settings.properties;
    }

    if (typeof this.storageApi === 'string') {
      const _storageApi = STORAGE_APIS[this.storageApi] || API_HEAD;
      this.supportsRevs = _storageApi >= API_00;
    }
    if (this.href && this.token) {
      this.connected = true;
      this.online = true;
      this._emit('connected');
    } else {
      this.connected = false;
    }
    if (hasLocalStorage) {
      localStorage[SETTINGS_KEY] = JSON.stringify({
        userAddress: this.userAddress,
        href: this.href,
        storageApi: this.storageApi,
        token: this.token,
        properties: this.properties
      });
    }
  }

  get (path: string, options: { ifNoneMatch?: string } = {}): Promise<RemoteResponse> {
    if (!this.connected) {
      return Promise.reject('not connected (path: ' + path + ')');
    }

    const headers = {};
    if (this.supportsRevs) {
      if (options.ifNoneMatch) {
        headers['If-None-Match'] = this.addQuotes(options.ifNoneMatch);
      }
    }
    // commenting it out as this is doing nothing and jshint is complaining -les
    // else if (options.ifNoneMatch) {
    //   let oldRev = this._revisionCache[path];
    // }

    return this._request('GET', this.href + cleanPath(path), this.token, headers,
      undefined, this.supportsRevs, this._revisionCache[path])
      .then((r) => {
        if (!isFolder(path)) {
          return Promise.resolve(r);
        }
        let itemsMap = {};
        if (typeof (r.body) !== 'undefined') {
          try {
            r.body = JSON.parse(r.body);
          } catch (e) {
            return Promise.reject('Folder description at ' + this.href + cleanPath(path) + ' is not JSON');
          }
        }

        if (r.statusCode === 200 && typeof (r.body) === 'object') {
          // New folder listing received
          if (Object.keys(r.body).length === 0) {
            // Empty folder listing of any spec
            r.statusCode = 404;
          } else if (isFolderDescription(r.body)) {
            // >= 02 spec
            for (const item in r.body.items) {
              this._revisionCache[path + item] = r.body.items[item].ETag;
            }
            itemsMap = r.body.items;
          } else {
            // < 02 spec
            Object.keys(r.body).forEach((key) => {
              this._revisionCache[path + key] = r.body[key];
              itemsMap[key] = {'ETag': r.body[key]};
            });
          }
          r.body = itemsMap;
          return Promise.resolve(r);
        } else {
          return Promise.resolve(r);
        }
      });
  }

  put (path: string, body: XMLHttpRequestBodyInit, contentType: string, options: { ifMatch?: string; ifNoneMatch?: string } = {}): Promise<RemoteResponse> {
    if (!this.connected) {
      return Promise.reject('not connected (path: ' + path + ')');
    }
    if ((!contentType.match(/charset=/)) && (body instanceof ArrayBuffer || isArrayBufferView(body))) {
      contentType += '; charset=binary';
    }
    const headers = {'Content-Type': contentType};
    if (this.supportsRevs) {
      if (options.ifMatch) {
        headers['If-Match'] = this.addQuotes(options.ifMatch);
      }
      if (options.ifNoneMatch) {
        headers['If-None-Match'] = this.addQuotes(options.ifNoneMatch);
      }
    }
    return this._request('PUT', this.href + cleanPath(path), this.token,
      headers, body, this.supportsRevs);
  }

  delete (path: string, options: { ifMatch?: string } = {}): Promise<RemoteResponse> {
    if (!this.connected) {
      throw new Error('not connected (path: ' + path + ')');
    }
    if (!options) {
      options = {};
    }
    const headers = {};
    if (this.supportsRevs) {
      if (options.ifMatch) {
        headers['If-Match'] = this.addQuotes(options.ifMatch);
      }
    }
    return this._request('DELETE', this.href + cleanPath(path), this.token,
      headers,
      undefined, this.supportsRevs);
  }

  static _rs_init (remoteStorage): void {
    remoteStorage.remote = new WireClient(remoteStorage);
    remoteStorage.remote.online = true;
  }

  static _rs_supported (): boolean {
    return typeof fetch === 'function' || typeof XMLHttpRequest === 'function';
  }

  static _rs_cleanup (): void {
    if (hasLocalStorage) {
      delete localStorage[SETTINGS_KEY];
    }
  }
}

interface WireClient extends EventHandling {}
applyMixins(WireClient, [EventHandling]);

export = WireClient;