Extract HttpHypermediaClient from Api
authorFrank Bessou <frank.bessou@logilab.fr>
Wed, 19 Apr 2017 11:02:02 +0200
changeset 127 fa463ba929c3
parent 126 ea6ec8069edd
child 128 dc40f95bf0e5
Extract HttpHypermediaClient from Api Extract all methods which are not related to cubicweb into a new HttpHypermediaClient class.
src/Api.js
src/components/Entity.js
src/components/Root.js
src/services/hypermedia.js
test/index.js
--- a/src/Api.js	Tue Apr 18 14:50:22 2017 +0200
+++ b/src/Api.js	Wed Apr 19 11:02:02 2017 +0200
@@ -1,60 +1,20 @@
-/* global fetch, API_URL, SCRIPT_NAME */
+/* global fetch */
 
 import 'whatwg-fetch';
 import {isEmpty} from 'lodash/lang';
-import {get, defaultsDeep} from 'lodash/object';
-import {appendPath} from './utils';
-import {wrapEntityData} from './jsonaryutils';
-import LinkParser from 'http-link-header';
+import {get} from 'lodash/object';
+
+import HypermediaClient from "./services/hypermedia.js";
 
 export class Api {
 
-    constructor(baseUrl, fetchFunc) {
-        if (baseUrl) {
-            this.baseUrl = baseUrl;
-        } else if (typeof SCRIPT_NAME !== 'undefined') {
-            this.baseUrl = SCRIPT_NAME;
-        } else if (typeof API_URL !== 'undefined') {
-            this.baseUrl = API_URL;
-        }
-        fetchFunc = fetchFunc || fetch;
-        this.fetch = fetchFunc.bind(window);
-    }
-
-    buildUrl(path) {
-        return appendPath(this.baseUrl, path);
-    }
-
-    jsonFetchResponse(url, options = {}) {
-        const fullUrl = this.buildUrl(url);
-        defaultsDeep(options, {credentials: 'same-origin',
-            headers: {Accept: 'application/json'}});
-        return this.fetch(fullUrl, options)
-            .then(response => {
-                const contentType = response.headers.get("content-type");
-                if(response.status === 204) {
-                    return response;
-                } else if (contentType && contentType.toLowerCase().indexOf("application/json") >= 0) {
-                    return response;
-                }
-                const method = options.method || 'GET';
-                throw new Error(
-                        `Got "${response.statusText}" from ${method} request at ${fullUrl}`);
-            });
-    }
-
-    jsonFetch(url, options = {}) {
-        return this.jsonFetchResponse(url, options).then(response => response.json());
-    }
-
-    getSchema(url, options = {}) {
-        defaultsDeep(options, {headers: {Accept: 'application/schema+json'}});
-        return this.jsonFetch(url, options);
+    constructor(hypermediaClient) {
+        this.hypermediaClient = hypermediaClient;
     }
 
     jsonFetchCollection(url, options) {
         // Filter out error response by returning and empty collection.
-        return this.jsonFetch(url, options)
+        return this.hypermediaClient.jsonFetch(url, options)
             .then(doc => {
                 if (doc.hasOwnProperty('errors')) {
                     console.error(doc);
@@ -64,72 +24,18 @@
             });
     }
 
-    getResource(resourceRoute) {
-        const resource = {url: resourceRoute};
-        // Fetch resource
-        const dataResponsePromise = this.jsonFetchResponse(resource.url);
-        const resourcePromise = dataResponsePromise.then(response => {
-            resource.allowedActions = this.extractAllowedActions(response);
-            const schemaRoute = this.extractSchemaRoute(response);
-            const schemaPromise = this.getSchema(schemaRoute);
-            const dataPromise = response.json().catch(()=>null);
-
-            return Promise.all([dataPromise, schemaPromise]).then(
-                    ([data, schema]) => {
-                        resource.data = wrapEntityData(data, schema);
-                        return resource;
-                    });
-        });
-        return resourcePromise;
-    }
-
-    extractAllowedActions(response) {
-        if (!response.headers.has('Allow')) {
-            return ['view'];
-        }
-
-        const allowHeader = response.headers.get('Allow');
-        const allowedMethods = allowHeader.split(/[ ,]+/);
-        return allowedMethods.map((method) => {
-            switch(method) {
-                case 'POST':
-                    return 'create';
-                case 'GET':
-                    return 'view';
-                case 'PUT':
-                case 'PATCH':
-                    return 'edit';
-                case 'DELETE':
-                    return 'delete';
-            }
-        });
-    }
-
-    extractSchemaRoute(response) {
-        const linkHeader = response.headers.get("Link");
-        if (linkHeader === null) {
-            throw new Error(`"Link" header does not exist on resource's HTTP header`)
-        }
-        try {
-            const links = LinkParser.parse(linkHeader);
-            return links.rel('describedby')[0].uri;
-        } catch (e) {
-            throw new Error(`Cannot find 'describedby' link in ${linkHeader}`);
-        }
-    }
-
     getRelatedSchema(etype, rtype, role = 'creation', targetType = null) {
         let url = `/${etype}/relationships/${rtype}/schema?role=${role}`;
         if (targetType !== null) {
             url += `&target_type=${targetType}`;
         }
-        return this.jsonSchemaFetch(url);
+        return this.hypermediaClient.jsonSchemaFetch(url);
     }
 
     getEntities(etype) {
         const route = `/${etype}/`;
         const url = route;
-        return this.getResource(url).then( resource => {
+        return this.hypermediaClient.getResource(url).then( resource => {
             resource.etype = etype;
             resource.route = route;
             return resource;
@@ -145,13 +51,13 @@
                 searchParams.append('sort', sort);
             }
         }
-        return this.jsonFetchCollection(`${url}?${searchParams.toString()}`);
+        return this.hypermediaClient.jsonFetchCollection(`${url}?${searchParams.toString()}`);
     }
 
     getEntity(etype, eid) {
         const route = `/${etype}/${eid}`;
         const url = route;
-        return this.getResource(url).then(resource => {
+        return this.hypermediaClient.getResource(url).then(resource => {
             resource.etype = etype;
             resource.eid = eid;
             resource.route = route;
@@ -177,7 +83,7 @@
             headers: headers,
             body: body,
         };
-        return this.jsonFetch(url, options);
+        return this.hypermediaClient.jsonFetch(url, options);
     }
 
     relateEntity(etype, eid, rtype, attributes, targetType = null) {
@@ -194,7 +100,7 @@
         if (attributes !== undefined) {
             options.body = JSON.stringify(attributes);
         }
-        return this.jsonFetch(url, options);
+        return this.hypermediaClient.jsonFetch(url, options);
     }
 
     updateEntity(etype, eid, attributes) {
@@ -206,26 +112,17 @@
             },
             body: JSON.stringify(attributes),
         };
-        return this.jsonFetch(url, options);
+        return this.hypermediaClient.jsonFetch(url, options);
     }
 
     deleteEntity(etype, eid) {
         const url = `/${etype}/${eid}`;
-        const options = {
-            method: 'DELETE',
-        };
-        return this.fetch(this.buildUrl(url), options)
-            .then( response => response.status)
-            .then( status => {
-                if (status !== 204 && status !== 404) {
-                    throw new Error(`Could not delete resource /${etype}/${eid}`);
-                }
-            });
+        return this.hypermediaClient.deleteResource(url);
     }
 
     getTransitionsSchema(etype, eid) {
         const url = `/${etype}/${eid}/transitions/schema?role=creation`;
-        return this.jsonSchemaFetch(url);
+        return this.hypermediaClient.jsonSchemaFetch(url);
     }
 
     addTransition(etype, eid, attributes) {
@@ -237,8 +134,8 @@
             },
             body: JSON.stringify(attributes),
         };
-        return this.jsonFetch(url, options);
+        return this.hypermediaClient.jsonFetch(url, options);
     }
 }
 
-export default new Api();
+export default new Api(HypermediaClient);
--- a/src/components/Entity.js	Tue Apr 18 14:50:22 2017 +0200
+++ b/src/components/Entity.js	Wed Apr 19 11:02:02 2017 +0200
@@ -6,6 +6,7 @@
 import {merge} from 'lodash/object';
 
 import Api from '../Api';
+import HypermediaClient from '../services/hypermedia';
 import {wrapEntityData, PropTypeJsonaryWrapper} from '../jsonaryutils'
 import {PropTypesEntityModel} from '../model';
 import {buildFormData, appendPath} from '../utils';
@@ -211,7 +212,7 @@
     }
 
     getEditionSchema() {
-        return Api.getSchema(appendPath(this.state.entity.url, '/schema?role=edition'));
+        return HypermediaClient.getSchema(appendPath(this.state.entity.url, '/schema?role=edition'));
     }
 
     navigateToSelf() {
@@ -394,7 +395,7 @@
 
     componentDidMount() {
         const {etype} = this.props.params;
-        Api.getSchema(appendPath(`/${etype}`, '/schema?role=creation'))
+        HypermediaClient.getSchema(appendPath(`/${etype}`, '/schema?role=creation'))
             .then(schema => this.setState({schema: schema}));
     }
 
--- a/src/components/Root.js	Tue Apr 18 14:50:22 2017 +0200
+++ b/src/components/Root.js	Wed Apr 19 11:02:02 2017 +0200
@@ -1,6 +1,6 @@
 import React from 'react';
 import {Link} from 'react-router';
-import Api from '../Api';
+import HypermediaClient from '../services/hypermedia';
 
 export class Root extends React.Component {
     constructor() {
@@ -9,7 +9,7 @@
     }
 
     componentDidMount() {
-        Api.getResource('').then(resource => {
+        HypermediaClient.getResource('').then(resource => {
             this.setState({resource: resource});
         });
     }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/services/hypermedia.js	Wed Apr 19 11:02:02 2017 +0200
@@ -0,0 +1,125 @@
+/* global fetch, API_URL, SCRIPT_NAME */
+
+import 'whatwg-fetch';
+import LinkParser from 'http-link-header';
+import {defaultsDeep} from 'lodash/object';
+
+import {wrapEntityData} from '../jsonaryutils';
+import {appendPath} from '../utils';
+
+export class HttpHypermediaClient {
+
+    constructor(baseUrl, fetchFunc) {
+        if (baseUrl) {
+            this.baseUrl = baseUrl;
+        } else if (typeof SCRIPT_NAME !== 'undefined') {
+            this.baseUrl = SCRIPT_NAME;
+        } else if (typeof API_URL !== 'undefined') {
+            this.baseUrl = API_URL;
+        }
+        fetchFunc = fetchFunc || fetch;
+        this.fetch = fetchFunc.bind(window);
+    }
+
+    getResource(resourceRoute) {
+        const resource = {url: resourceRoute};
+        // Fetch resource
+        const dataResponsePromise = this.jsonFetchResponse(resource.url);
+        const resourcePromise = dataResponsePromise.then(response => {
+            resource.allowedActions = this.extractAllowedActions(response);
+            const schemaRoute = this.extractSchemaRoute(response);
+            const schemaPromise = this.getSchema(schemaRoute);
+            const dataPromise = response.json().catch(()=>null);
+
+            return Promise.all([dataPromise, schemaPromise]).then(
+                    ([data, schema]) => {
+                        resource.data = wrapEntityData(data, schema);
+                        return resource;
+                    });
+        });
+        return resourcePromise;
+    }
+
+    getSchema(url, options = {}) {
+        defaultsDeep(options, {headers: {Accept: 'application/schema+json'}});
+        return this.jsonFetch(url, options);
+    }
+
+    extractAllowedActions(response) {
+        if (!response.headers.has('Allow')) {
+            return ['view'];
+        }
+
+        const allowHeader = response.headers.get('Allow');
+        const allowedMethods = allowHeader.split(/[ ,]+/);
+        return allowedMethods.map((method) => {
+            switch(method) {
+                case 'POST':
+                    return 'create';
+                case 'GET':
+                    return 'view';
+                case 'PUT':
+                case 'PATCH':
+                    return 'edit';
+                case 'DELETE':
+                    return 'delete';
+            }
+        });
+    }
+
+    extractSchemaRoute(response) {
+        const linkHeader = response.headers.get("Link");
+        if (linkHeader === null) {
+            throw new Error(`"Link" header does not exist on resource's HTTP header`)
+        }
+        try {
+            const links = LinkParser.parse(linkHeader);
+            return links.rel('describedby')[0].uri;
+        } catch (e) {
+            throw new Error(`Cannot find 'describedby' link in ${linkHeader}`);
+        }
+    }
+
+    jsonFetchResponse(url, options = {}) {
+        const fullUrl = this.buildUrl(url);
+        defaultsDeep(options, {credentials: 'same-origin',
+            headers: {Accept: 'application/json'}});
+        return this.fetch(fullUrl, options)
+            .then(response => {
+                const contentType = response.headers.get("content-type");
+                if(response.status === 204) {
+                    return response;
+                } else if (contentType && contentType.toLowerCase().indexOf("application/json") >= 0) {
+                    return response;
+                }
+                const method = options.method || 'GET';
+                throw new Error(
+                        `Got "${response.statusText}" from ${method} request at ${fullUrl}`);
+            });
+    }
+
+    deleteResource(url) {
+        const options = {
+            method: 'DELETE',
+        };
+        url =this.buildUrl(url);
+        return this.fetch(url, options)
+            .then( response => response.status)
+            .then( status => {
+                if (status !== 204 && status !== 404) {
+                    throw new Error(`Could not delete resource \'${url}\'`);
+                }
+            });
+
+    }
+
+    buildUrl(path) {
+        return appendPath(this.baseUrl, path);
+    }
+
+    jsonFetch(url, options = {}) {
+        return this.jsonFetchResponse(url, options).then(response => response.json());
+    }
+}
+
+export default new HttpHypermediaClient();
--- a/test/index.js	Tue Apr 18 14:50:22 2017 +0200
+++ b/test/index.js	Wed Apr 19 11:02:02 2017 +0200
@@ -14,11 +14,11 @@
 
 import {buildFormData, appendPath} from '../src/utils';
 import {Api} from '../src/Api';
-import defaultApi from '../src/Api';
 import {ActionLink, ActionsDropDown, CollectionItemLink} from '../src/components/BaseViews';
 import {EntityMeta} from '../src/components/Entity';
 import {wrapEntityData} from "../src/jsonaryutils";
 import {ReactJsonSchemaAdapterFactory} from '../src/components/Form';
+import {HttpHypermediaClient} from '../src/services/hypermedia.js';
 
 const userEditionSchema = {
     "$ref": "#/definitions/CWUser",
@@ -198,11 +198,6 @@
         targetResource = {
             route: "/any/123",
         };
-        sinon.stub(defaultApi, 'buildUrl').callsFake(x => `http://testing.com${x}`);
-    });
-
-    afterEach(() => {
-        defaultApi.buildUrl.restore();
     });
 
     it('given a "create" action, it renders a link pointing to "create" view', () => {
@@ -388,25 +383,9 @@
     });
 });
 
-describe('Api', () => {
-    const baseUrl = 'http://example.com';
-
-    describe('jsonFetch', () => {
-        const anyUrl = 'http://xyz.com';
-        const validResponse = new window.Response('{}', {headers: {'content-type': 'application/json'}});
-
-        it('should call fetch function provided in constructor', () => {
-            const fetch = sinon.stub().resolves(validResponse);
-            const api = new Api(baseUrl, fetch);
-
-            api.jsonFetch(anyUrl);
-
-            expect(fetch.calledOnce).to.be.true;
-        });
-    });
+describe('HttpHypermediaClient', () => {
 
     describe('getResource', () => {
-
         const baseUrl = 'http://example.com';
         let fakeFetch;
 
@@ -423,8 +402,8 @@
             const options = config.options || {};
 
             fakeFetch = sinon.stub();
-            const api = new Api(baseUrl, fakeFetch);
-            sinon.stub(api, 'jsonFetchResponse').callsFake(() => {
+            const client = new HttpHypermediaClient(baseUrl, fakeFetch);
+            sinon.stub(client, 'jsonFetchResponse').callsFake(() => {
                 const resourceOptions = merge({headers: {Allow: allow}}, options);
                 if (!noLink) {
                     const linkHeader = customLink || `<${schemaRoute}>; rel=describedby`;
@@ -439,22 +418,22 @@
                 return Promise.resolve(response);
             });
 
-            sinon.stub(api, 'getSchema').callsFake(() => {
+            sinon.stub(client, 'getSchema').callsFake(() => {
                 const schemaOptions = {headers: {"Content-Type": "application/json"}};
                 const response = new Response(JSON.stringify(schema), schemaOptions);
                 return Promise.resolve(response);
             });
 
-            return api;
+            return client;
         }
 
         it('should fetch resource', done => {
             const resourceData = {name: 'John Doe'};
             const resourceRoute = '/users/john';
 
-            const api = configureBackend({data: resourceData, resourceRoute: resourceRoute});
+            const client = configureBackend({data: resourceData, resourceRoute: resourceRoute});
 
-            api.getResource(resourceRoute).then( resource => {
+            client.getResource(resourceRoute).then( resource => {
                 expect(resource.data.value()).to.deep.equal(resourceData);
                 done()
             }).catch(done);
@@ -468,13 +447,13 @@
                 schemaRoute : '/schema/any',
 
             }
-            const api = configureBackend(config);
+            const client = configureBackend(config);
 
-            api.getResource(config.resourceRoute).then(resource => {
+            client.getResource(config.resourceRoute).then(resource => {
                 const schema = resource.data.schemas()[0].data.value();
 
                 expect(schema).to.deep.equal(schema);
-                assert.calledWith(api.getSchema, sinon.match(config.schemaRoute));
+                assert.calledWith(client.getSchema, sinon.match(config.schemaRoute));
                 done();
             }).catch(done);
         });
@@ -485,9 +464,9 @@
                     resourceRoute: '/',
                     noLink: true,
                 };
-                const api = configureBackend(config);
+                const client = configureBackend(config);
 
-                return expect(api.getResource('/')).to.eventually.be.rejectedWith(/"Link" header does not exist./);
+                return expect(client.getResource('/')).to.eventually.be.rejectedWith(/"Link" header does not exist./);
             });
         });
 
@@ -497,15 +476,33 @@
                     resourceRoute: '/',
                     customLink: '</> ; rel="collection"',
                 };
-                const api = configureBackend(config);
+                const client = configureBackend(config);
 
-                return expect(api.getResource('/')).to.eventually.be.rejectedWith(/Cannot find 'describedby'/);
+                return expect(client.getResource('/')).to.eventually.be.rejectedWith(/Cannot find 'describedby'/);
             });
         });
-    });
+
+        describe('jsonFetch', () => {
+            const anyUrl = 'http://xyz.com';
+            const validResponse = new window.Response('{}', {headers: {'content-type': 'application/json'}});
+
+            it('should call fetch function provided in constructor', () => {
+                const fetch = sinon.stub().resolves(validResponse);
+                const client = new HttpHypermediaClient(baseUrl, fetch);
+
+                client.jsonFetch(anyUrl);
+
+                expect(fetch.calledOnce).to.be.true;
+            });
+        });
+    })
+});
+
+describe('Api', () => {
 
     describe('getEntity', () => {
-        const api = new Api(baseUrl);
+        const hypermediaClient = new HttpHypermediaClient();
+        const api = new Api(hypermediaClient);
 
         const etype = 'foo';
         const eid = 123;
@@ -513,11 +510,11 @@
         const resource = {url: route, route: route, allowedActions:['view'], data: wrapEntityData('{}', '{}')};
 
         beforeEach(() => {
-            sinon.stub(api, 'getResource').resolves(resource);
+            sinon.stub(hypermediaClient, 'getResource').resolves(resource);
         });
 
         afterEach(() => {
-            api.getResource.restore();
+            hypermediaClient.getResource.restore();
         });
 
         it('should build an entity object containing getResource\'s result, etype and eid', done => {