Skip to content

Commit

Permalink
Merge pull request #88 from mozilla-services/deprecation-events
Browse files Browse the repository at this point in the history
Refs #81, #84 - Add support for backoff and deprecated public events.
  • Loading branch information
n1k0 committed Jul 22, 2015
2 parents a0e3ec5 + df5a132 commit 26c5666
Show file tree
Hide file tree
Showing 13 changed files with 427 additions and 173 deletions.
127 changes: 87 additions & 40 deletions demo/kinto.dev.js

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions demo/kinto.min.js

Large diffs are not rendered by default.

127 changes: 87 additions & 40 deletions dist/kinto.dev.js

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions dist/kinto.min.js

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,38 @@ While not necessarily recommended, if you ever want to bypass this restriction,
articles.sync({ignoreBackoff: true})
.then(…)
```

## Events

The `Kinto` instance and its other dependencies expose an `events` property you can subscribe public events from. That `events` property implements nodejs' [EventEmitter interface](https://nodejs.org/api/events.html#events_class_events_eventemitter).

### The `backoff` event

Triggered when a `Backoff` HTTP header has been received from the last recevied response from the server, meaning clients should hold on performing further requests during a given amount of time.

The `backoff` event notifies what's the backoff release timestamp you should wait until before performing another `#sync()` call:

```js
const kinto = new Kinto();

kinto.events.on("backoff", function(releaseTime) {
const releaseDate = new Date(releaseTime).toLocaleString();
alert(`Backed off; wait until ${releaseDate} to retry`);
});
```

### The `deprecated` event

Triggered when an `Alert` HTTP header is received from the server, meaning that the service has been deprecated; the `event` argument received by the event listener contains the following deprecation information:

- `type`: The type of deprecation, which in ou case is always `soft-eol` (`hard-eol` alerts trigger an `HTTP 410 Gone` error);
- `message`: The deprecation alert message;
- `url`: The URL you can get information about the related deprecation policy.

```js
const kinto = new Kinto();

kinto.events.on("deprecated", function(event) {
console.log(event.message);
});
```
28 changes: 21 additions & 7 deletions src/api.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use strict";

import { EventEmitter } from "events";
import { quote, unquote } from "./utils.js";
import ERROR_CODES from "./errors.js";
import HTTP from "./http.js";
Expand All @@ -16,24 +17,40 @@ export function cleanRecord(record, excludeFields=RECORD_FIELDS_TO_CLEAN) {
}, {});
};

/**
* Api class.
*/
export default class Api {
/**
* Constructor.
*
* Options:
* - {Object} headers: The key-value headers to pass to each request.
* - {EventEmitter} events: The events handler.
*
* @param {String} remote The remote URL.
* @param {Object} options The options object.
*/
constructor(remote, options={headers: {}}) {
if (typeof(remote) !== "string" || !remote.length)
throw new Error("Invalid remote URL: " + remote);
if (remote[remote.length-1] === "/")
remote = remote.slice(0, -1);
this._backoffReleaseTime = null;
// public properties
this.remote = remote;
this.optionHeaders = options.headers;
this.serverSettings = null;
this._backoffReleaseTime = null;
this.events = options.events || new EventEmitter();
try {
this.version = remote.match(/\/(v\d+)\/?$/)[1];
} catch (err) {
throw new Error("The remote URL must contain the version: " + remote);
}
if (this.version !== SUPPORTED_PROTOCOL_VERSION)
throw new Error(`Unsupported protocol version: ${this.version}`);
this.http = this._registerHTTPEvents(new HTTP());
this.http = new HTTP({events: this.events});
this._registerHTTPEvents();
}

/**
Expand All @@ -51,12 +68,9 @@ export default class Api {

/**
* Registers HTTP events.
*
* @param {HTTP} http The HTTP instance.
* @return {HTTP}
*/
_registerHTTPEvents(http) {
return http.on("backoff", backoffMs => {
_registerHTTPEvents() {
this.events.on("backoff", backoffMs => {
this._backoffReleaseTime = backoffMs;
});
}
Expand Down
22 changes: 13 additions & 9 deletions src/collection.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use strict";

import { EventEmitter } from "events";
import { v4 as uuid4 } from "uuid";
import deepEquals from "deep-eql";

Expand Down Expand Up @@ -31,27 +32,30 @@ export class SyncResultObject {
if (!Array.isArray(this[type]))
return;
this[type] = this[type].concat(entries);
this.ok = this.errors.length + this.conflicts.length === 0
this.ok = this.errors.length + this.conflicts.length === 0;
}
}

/**
* Collection class.
*/
export default class Collection {

/**
* Ensures a connection to the local database has been opened.
*
* @param {String} bucket Bucket identifier.
* @param {String} name Collection name.
* @param {Api} api Reference to Api instance.
* Constructor.
*
* @return {Promise}
* @param {String} bucket The bucket identifier.
* @param {String} name The collection name.
* @param {Api} api The Api instance.
* @param {Object} options The options object.
*/
constructor(bucket, name, api) {
constructor(bucket, name, api, options={}) {
this._bucket = bucket;
this._name = name;
this._db;
this._lastModified = null;
// public properties
this.api = api;
this.events = options.events || new EventEmitter();
}

get name() {
Expand Down
26 changes: 16 additions & 10 deletions src/http.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
"use strict";

import ERROR_CODES from "./errors.js";
import { EventEmitter } from "events";
import ERROR_CODES from "./errors.js";

export default class HTTP extends EventEmitter {
/**
* HTTP class.
*/
export default class HTTP {
static get DEFAULT_REQUEST_HEADERS() {
return {
"Accept": "application/json",
Expand All @@ -15,13 +18,13 @@ export default class HTTP extends EventEmitter {
* Constructor.
*
* Options:
* - {Number} backoffRelease Backoff release timestamp.
* - {EventEmitter} events Events handler.
*
* @param {Object} options [description]
* @return {[type]} [description]
* @param {Object} options The options object.
*/
constructor() {
super();
constructor(options={}) {
// public properties
this.events = options.events || new EventEmitter();
}

/**
Expand Down Expand Up @@ -83,12 +86,15 @@ export default class HTTP extends EventEmitter {
const alertHeader = headers.get("Alert");
if (!alertHeader)
return;
var alert;
try {
const {message, url} = JSON.parse(alertHeader);
console.warn(message, url);
alert = JSON.parse(alertHeader);
} catch(err) {
console.warn("Unable to parse Alert header message", alertHeader);
return;
}
console.warn(alert.message, alert.url);
this.events.emit("deprecated", alert);
}

_checkForBackoffHeader(status, headers) {
Expand All @@ -103,6 +109,6 @@ export default class HTTP extends EventEmitter {
} else {
backoffMs = 0;
}
this.emit("backoff", backoffMs);
this.events.emit("backoff", backoffMs);
}
}
31 changes: 24 additions & 7 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,45 @@ import "isomorphic-fetch";

import Api from "./api";
import Collection from "./collection";

import { EventEmitter } from "events";

const DEFAULT_BUCKET_NAME = "default";


/**
* Kinto class.
*/
export default class Kinto {
constructor(options = {}) {
/**
* Constructor.
*
* Options:
* - {String} bucket The collection bucket name.
* - {EventEmitter} events Events handler.
*
* @param {Object} options The options object.
*/
constructor(options={}) {
this._options = options;
this._collections = {};
// public properties
this.events = options.events || new EventEmitter();
}

collection(collName) {
if (!collName)
throw new Error("missing collection name");

const bucket = this._options.bucket || DEFAULT_BUCKET_NAME;
const api = new Api(this._options.remote || "http://0.0.0.0:8888/v1", {
headers: this._options.headers || {}
const api = new Api(this._options.remote || "http://localhost:8888/v1", {
headers: this._options.headers || {},
events: this.events,
});

if (!this._collections.hasOwnProperty(collName))
this._collections[collName] = new Collection(bucket, collName, api);
if (!this._collections.hasOwnProperty(collName)) {
this._collections[collName] = new Collection(bucket, collName, api, {
events: this.events
});
}

return this._collections[collName];
}
Expand Down
27 changes: 20 additions & 7 deletions test/api_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import { EventEmitter } from "events";
import { quote } from "../src/utils";
import { fakeServerResponse } from "./test_utils.js";
import Api, { SUPPORTED_PROTOCOL_VERSION as SPV, cleanRecord } from "../src/api";
Expand Down Expand Up @@ -41,6 +42,20 @@ describe("Api", () => {
expect(new Api(`http://test/${SPV}/`).remote).eql(`http://test/${SPV}`);
});

it("should expose a passed events instance option", () => {
const events = new EventEmitter();
expect(new Api(`http://test/${SPV}`, {events}).events).to.eql(events);
});

it("should create an events property if none passed", () => {
expect(new Api(`http://test/${SPV}`).events).to.be.an.instanceOf(EventEmitter);
});

it("should propagate its events property to child dependencies", () => {
const api = new Api(`http://test/${SPV}`);
expect(api.http.events).eql(api.events);
});

it("should assign version value", () => {
expect(new Api(`http://test/${SPV}`).version).eql(SPV);
expect(new Api(`http://test/${SPV}/`).version).eql(SPV);
Expand All @@ -52,7 +67,7 @@ describe("Api", () => {
});

it("should validate protocol version", () => {
expect(() =>new Api(`http://test/v999`))
expect(() => new Api(`http://test/v999`))
.to.Throw(Error, /^Unsupported protocol version/);
});
});
Expand All @@ -64,17 +79,15 @@ describe("Api", () => {
sandbox.stub(root, "fetch").returns(
fakeServerResponse(200, {}, {Backoff: "1000"}));

return api.http.on("backoff", value => {
expect(api.backoff).eql(1000000);
}).request("/");
return api.fetchChangesSince()
.then(_ => expect(api.backoff).eql(1000000));
});

it("should provide no remaining backoff time when none is set", () => {
sandbox.stub(root, "fetch").returns(fakeServerResponse(200, {}, {}));

return api.http.on("backoff", value => {
expect(api.backoff).eql(0);
}).request("/");
return api.fetchChangesSince()
.then(_ => expect(api.backoff).eql(0));
});
});

Expand Down
35 changes: 31 additions & 4 deletions test/collection_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import chai, { expect } from "chai";
import chaiAsPromised from "chai-as-promised";
import sinon from "sinon";
import { EventEmitter } from "events";
import { v4 as uuid4 } from "uuid";

import Collection, { SyncResultObject } from "../src/collection";
Expand All @@ -17,12 +18,13 @@ const TEST_COLLECTION_NAME = "kinto-test";
const FAKE_SERVER_URL = "http://fake-server/v1"

describe("Collection", () => {
var sandbox, api;
var sandbox, events, api;
const article = {title: "foo", url: "http://foo"};

function testCollection() {
api = new Api(FAKE_SERVER_URL);
return new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api);
events = new EventEmitter();
api = new Api(FAKE_SERVER_URL, {events});
return new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {events});
}

beforeEach(() => {
Expand All @@ -34,6 +36,30 @@ describe("Collection", () => {
sandbox.restore();
});

describe("#constructor", () => {
it("should expose a passed events instance", () => {
const events = new EventEmitter();
const api = new Api(FAKE_SERVER_URL, {events});
const collection = new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {events});
expect(collection.events).to.eql(events);
});

it("should create an events property if none passed", () => {
const events = new EventEmitter();
const api = new Api(FAKE_SERVER_URL, {events});
const collection = new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api);
expect(collection.events).to.be.an.instanceOf(EventEmitter);
});

it("should propagate its events property to child dependencies", () => {
const events = new EventEmitter();
const api = new Api(FAKE_SERVER_URL, {events});
const collection = new Collection(TEST_BUCKET_NAME, TEST_COLLECTION_NAME, api, {events});
expect(collection.api.events).eql(collection.events);
expect(collection.api.http.events).eql(collection.events);
});
});

describe("#open", () => {
it("should resolve with current instance", () => {
var articles = testCollection();
Expand Down Expand Up @@ -325,7 +351,8 @@ describe("Collection", () => {
});

it("should isolate records by bucket", () => {
const otherbucket = new Collection('other', TEST_COLLECTION_NAME, api);
// FIXME: https://github.com/mozilla-services/kinto.js/issues/89
const otherbucket = new Collection("other", TEST_COLLECTION_NAME, api);
return otherbucket.get(uuid)
.then(res => res.data)
.should.be.rejectedWith(Error, /not found/);
Expand Down

0 comments on commit 26c5666

Please sign in to comment.