Fetch resource first, then use 'describedby' link to fetch schema
authorFrank Bessou <frank.bessou@logilab.fr>
Thu, 13 Apr 2017 11:51:58 +0200
changeset 115 706f374811ab
parent 114 82e5abf95f92
child 116 9ccdace2a88c
Fetch resource first, then use 'describedby' link to fetch schema It also remove most of the tests of getEntity method as they are basically the same as getResource's tests.
package.json
src/Api.js
test/index.js
--- a/package.json	Thu Apr 13 11:28:25 2017 +0200
+++ b/package.json	Thu Apr 13 11:51:58 2017 +0200
@@ -28,6 +28,7 @@
     "eslint": "^3.11.1",
     "eslint-plugin-react": "^6.7.1",
     "html-webpack-plugin": "^2.28.0",
+    "http-link-header": "^0.8.0",
     "json-loader": "^0.5.4",
     "jsonary": "0.0.16",
     "karma": "^1.5.0",
--- a/src/Api.js	Thu Apr 13 11:28:25 2017 +0200
+++ b/src/Api.js	Thu Apr 13 11:51:58 2017 +0200
@@ -5,6 +5,7 @@
 import {get, defaultsDeep} from 'lodash/object';
 import {appendPath} from './utils';
 import {wrapEntityData} from './jsonaryutils';
+import LinkParser from 'http-link-header';
 
 export class Api {
 
@@ -65,16 +66,17 @@
 
     getResource(resourceRoute) {
         const resource = {url: resourceRoute};
-        // Fetch resource schema
-        const schemaPromise = this.getSchema(appendPath(resourceRoute, '/schema'));
-        const resourcePromise = schemaPromise.then(schema => {
-            const fetchPromise = this.jsonFetchResponse(resource.url);
-            const dataPromise = fetchPromise.then( response => response.json() ).catch(() => null);
-            const allowedActionsPromise = fetchPromise.then( response => this.extractAllowedActions(response) );
-            // Load resource data and retrieve allowed actions
-            return Promise.all([dataPromise, allowedActionsPromise]).then(
-                    ([data, allowedActions]) => {
-                        resource.allowedActions = allowedActions;
+        // Fetch resource
+        const dataResponsePromise = this.jsonFetchResponse(resource.url);
+        const resourcePromise = dataResponsePromise.then(response => {
+            resource.allowedActions = this.extractAllowedActions(response);
+            const links = LinkParser.parse(response.headers.get('Link'));
+            const schemaRoute = links.rel('describedby')[0].uri;
+            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;
                     });
--- a/test/index.js	Thu Apr 13 11:28:25 2017 +0200
+++ b/test/index.js	Thu Apr 13 11:51:58 2017 +0200
@@ -402,7 +402,12 @@
     describe('getRoot', () => {
         it('should fetch the root schema ', done => {
             const rootSchema = JSON.stringify({foo: 'bar'});
-            const options = {headers: {"Content-Type": "application/json"}};
+            const options = {
+                headers: {
+                    "Content-Type": "application/json",
+                    'Link': '</schema>; rel=describedby'
+                },
+            };
             const fakeFetch = sinon.stub().resolves(new Response('null', options));
             fakeFetch.withArgs(baseUrl+'/schema', sinon.match({}))
                     .resolves(new Response(rootSchema, options));
@@ -425,85 +430,93 @@
         const baseUrl = 'http://example.com';
         let api;
         let fakeFetch;
-        let remoteResource;
+
+        function configureBackend(config) {
 
-        beforeEach(() => {
-            remoteResource = {allow: 'GET', data: {}, schema: {}};
+            const noContent = config.noContent || false;
+            const data = noContent ? undefined : (config.data || {});
+            const resourceRoute = config.resourceRoute || '/';
+            const schema = null;
+            const schemaRoute = config.schemaRoute || appendPath(resourceRoute, 'schema');
+            const allow = config.allow || 'GET';
+            const options = config.options || {};
+
             fakeFetch = sinon.stub();
-            const options = {headers: {"Content-Type": "application/json"}};
-            const dataOptions = merge({headers: {Allow: remoteResource.allow}}, options)
-            fakeFetch.withArgs(sinon.match(/\/schema$/), sinon.match({}))
-            .callsFake( _ =>
-                    Promise.resolve(new Response(JSON.stringify(remoteResource.schema), options)));
-            fakeFetch.callsFake( _ => Promise.resolve(new Response(JSON.stringify(remoteResource.data), dataOptions)));
             api = new Api(baseUrl, fakeFetch);
+            sinon.stub(api, 'jsonFetchResponse').callsFake(() => {
+                const linkHeader = `<${schemaRoute}>; rel=describedby`;
+                const resourceOptions = merge({headers: {Allow: allow, Link: linkHeader}}, options);
+                if (noContent) {
+                    merge(resourceOptions, {status: 204});
+                } else {
+                     merge(resourceOptions, {status: 200, headers: {"Content-Type": "application/json"}});
+                }
+                const response =  new Response(JSON.stringify(data), resourceOptions);
+                return Promise.resolve(response);
+            });
+
+            sinon.stub(api, 'getSchema').callsFake(() => {
+                const schemaOptions = {headers: {"Content-Type": "application/json"}};
+                const response = new Response(JSON.stringify(schema), schemaOptions);
+                return Promise.resolve(response);
+            });
+
+            return api;
+        }
+
+        it('should fetch resource', done => {
+            const resourceData = {name: 'John Doe'};
+            const resourceRoute = '/users/john';
+
+            const api = configureBackend({data: resourceData, resourceRoute: resourceRoute});
+
+            api.getResource(resourceRoute).then( resource => {
+                expect(resource.data.value()).to.deep.equal(resourceData);
+                done()
+            }).catch(done);
+
         });
 
-        it('should fetch schema', done => {
-            api.getResource('').then(resource => {
+        it('should fetch schema provided in HTTP "Link" header', done => {
+            const config = {
+                resourceRoute: '/users/john',
+                schema : {type: 'object'},
+                schemaRoute : '/schema/any',
+
+            }
+            configureBackend(config);
+
+            api.getResource(config.resourceRoute).then(resource => {
                 const schema = resource.data.schemas()[0].data.value();
 
-                assert.calledWith(fakeFetch, baseUrl + '/schema');
-                expect(schema).to.deep.equal(remoteResource.schema);
+                expect(schema).to.deep.equal(schema);
+                assert.calledWith(api.getSchema, sinon.match(config.schemaRoute));
                 done();
             }).catch(done);
         });
     });
 
     describe('getEntity', () => {
-        let api;
-        let fakeFetch;
-        let etype = 'foo';
-        let eid = 123;
-        const schemaJson = JSON.stringify({buzz: "fuzz", links: [{rel: 'self', href: '/foo/123'}]});
-        const dataJson =JSON.stringify({foo: "bar"});
+        let api = new Api(baseUrl);
+
+        const etype = 'foo';
+        const eid = 123;
+        const route = `/${etype}/${eid}`;
+        const resource = {url: route, route: route, allowedActions:['view'], data: wrapEntityData('{}','{}')};
 
         beforeEach(() => {
-            fakeFetch = sinon.stub();
-            const options = {headers: {"Allow": "GET", "Content-Type": "application/json"}};
-            fakeFetch.withArgs(sinon.match(/\/foo\/123$/), sinon.match({}))
-                .resolves(new Response(dataJson, options));
-
-            fakeFetch.withArgs(sinon.match(/\/foo\/123\/schema$/), sinon.match({}))
-                    .resolves(new Response(schemaJson, options));
-            api = new Api(baseUrl, fakeFetch);
-        });
-
-        it('should fetch the entity data', done => {
-            api.getEntity(etype, eid).then( _ => {
-
-                const dataUrl = baseUrl + '/foo/123';
-                assert.calledWith(fakeFetch, dataUrl, sinon.match({}));
-                done();
-            });
+            sinon.stub(api, 'getResource').resolves(resource);
         });
 
-        it('should fetch the entity schema', () => {
-            api.getEntity(etype, eid);
-
-            const schemaUrl = baseUrl + '/foo/123/schema';
-            const initOptions = {headers: {Accept: 'application/schema+json'}};
-            assert.calledWith(fakeFetch, schemaUrl, sinon.match(initOptions));
+        afterEach(() => {
+            api.getResource.restore();
         });
 
-        it('should return a Promise of EntityModel', done => {
-            api.getEntity(etype, eid).then(entity => {
-                expect(entity.url).to.exist;
-                expect(entity.route).to.exist;
-                expect(entity.etype).to.exist;
-                expect(entity.eid).to.exist;
-                expect(Jsonary.isData(entity.data)).to.be.true;
-                expect(entity.allowedActions).to.be.an('array');
-                done();
-            }).catch(done);
-        });
+        it('should build an entity object containing getResource\'s result, etype and eid', done => {
+            api.getEntity(etype, eid).then( entity => {
 
-        it('should wrap entity schema and data fetched from server into data property', done => {
-            api.getEntity(etype, eid).then( entity => {
-                const entityData = JSON.stringify(entity.data.value());
-                const entitySchema = JSON.stringify(entity.data.schemas()[0].data.value());
-                expect(entityData).to.equal(dataJson);
-                expect(entitySchema).to.equal(schemaJson);
+                const expectedEntity = merge({etype: etype, eid: eid}, resource);
+                expect(entity).to.deep.equal(expectedEntity);
                 done();
             }).catch(done);
         });