Commit 5b214a6e authored by Robert Knight's avatar Robert Knight

Add a service for fetching the API route directory and page links.

Fetching the API route directory is currently the responsibility of the
API client ("store") service. The "store" service makes authenticated
API calls and therefore depends on the "auth" service.

This means that we cannot use the API route directory or the page links
returned from `/api/links` in the auth service itself, as this would
introduce a circular dependency.

Factoring out the responsibility for fetching the `/api` and
`/api/links` endpoints into a separate service which does not use
authentication provides a way to resolve this problem.

It also makes testing some aspects of handling these endpoints, such as
caching and auto-retry if the HTTP request fails, a little easier.
parent 64a96124
'use strict';
var { retryPromiseOperation } = require('./retry-util');
/**
* A service which fetches and caches API route metadata.
*/
// @ngInject
function apiRoutes($http, settings) {
// Cache of route name => route metadata from API root.
var routeCache;
// Cache of links to pages on the service fetched from the API's "links"
// endpoint.
var linkCache;
function getJSON(url) {
return $http.get(url).then(({ status, data }) => {
if (status !== 200) {
throw new Error(`Fetching ${url} failed`);
}
return data;
});
}
/**
* Fetch and cache API route metadata.
*
* Routes are fetched without any authentication and therefore assumed to be
* the same regardless of whether the user is authenticated or not.
*
* @return {Promise<Object>} - Map of routes to route metadata.
*/
function routes() {
if (!routeCache) {
routeCache = retryPromiseOperation(() => getJSON(settings.apiUrl))
.then((index) => index.links);
}
return routeCache;
}
/**
* Fetch and cache service page links from the API.
*
* @return {Promise<Object>} - Map of link name to URL
*/
function links() {
if (!linkCache) {
linkCache = routes().then(routes => {
return getJSON(routes.links.url);
});
}
return linkCache;
}
return { routes, links };
}
module.exports = apiRoutes;
......@@ -191,6 +191,7 @@ module.exports = angular.module('h', [
.service('analytics', require('./analytics'))
.service('annotationMapper', require('./annotation-mapper'))
.service('annotationUI', require('./annotation-ui'))
.service('apiRoutes', require('./api-routes'))
.service('auth', authService)
.service('bridge', require('../shared/bridge'))
.service('drafts', require('./drafts'))
......
'use strict';
var apiRoutesFactory = require('../api-routes');
// Abridged version of the response returned by https://hypothes.is/api,
// with the domain name changed.
var apiIndexResponse = {
message: 'Annotation Service API',
links: {
annotation: {
read: {
url: 'https://annotation.service/api/annotations/:id',
method: 'GET',
description: 'Fetch an annotation',
},
},
links: {
url: 'https://annotation.service/api/links',
method: 'GET',
description: 'Fetch links to pages on the service',
},
},
};
// Abridged version of the response returned by https://hypothes.is/api/links,
// with the domain name changed.
var linksResponse = {
'forgot-password': 'https://annotation.service/forgot-password',
'help': 'https://annotation.service/docs/help',
'groups.new': 'https://annotation.service/groups/new',
'groups.leave': 'https://annotation.service/groups/:id/leave',
'search.tag': 'https://annotation.service/search?q=tag:":tag"',
'account.settings': 'https://annotation.service/account/settings',
'oauth.revoke': 'https://annotation.service/oauth/revoke',
'signup': 'https://annotation.service/signup',
'oauth.authorize': 'https://annotation.service/oauth/authorize',
};
describe('sidebar.api-routes', () => {
var apiRoutes;
var fakeHttp;
var fakeSettings;
function httpResponse(status, data) {
return Promise.resolve({ status, data });
}
beforeEach(() => {
// Use a Sinon stub rather than Angular's fake $http service here to avoid
// the hassles that come with mixing `$q` and regular promises.
fakeHttp = {
get: sinon.stub(),
};
fakeHttp.get.withArgs('https://annotation.service/api/')
.returns(httpResponse(200, apiIndexResponse));
fakeHttp.get.withArgs('https://annotation.service/api/links')
.returns(httpResponse(200, linksResponse));
fakeSettings = {
apiUrl: 'https://annotation.service/api/',
};
apiRoutes = apiRoutesFactory(fakeHttp, fakeSettings);
});
describe('#routes', () => {
it('returns the route directory', () => {
return apiRoutes.routes().then(routes => {
assert.deepEqual(routes, apiIndexResponse.links);
});
});
it('caches the route directory', () => {
// Call `routes()` multiple times, check that only one HTTP call is made.
return Promise.all([apiRoutes.routes(), apiRoutes.routes()])
.then(([routesA, routesB]) => {
assert.equal(routesA, routesB);
assert.equal(fakeHttp.get.callCount, 1);
});
});
it('retries the route fetch until it succeeds', () => {
fakeHttp.get.onFirstCall().returns(httpResponse(500, null));
return apiRoutes.routes().then(routes => {
assert.deepEqual(routes, apiIndexResponse.links);
});
});
});
describe('#links', () => {
it('returns page links', () => {
return apiRoutes.links().then(links => {
assert.deepEqual(links, linksResponse);
});
});
it('caches the returned links', () => {
// Call `links()` multiple times, check that only two HTTP calls are made
// (one for the index, one for the page links).
return Promise.all([apiRoutes.links(), apiRoutes.links()])
.then(([linksA, linksB]) => {
assert.equal(linksA, linksB);
assert.deepEqual(fakeHttp.get.callCount, 2);
});
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment