Repository: api-platform/admin
Pull request #96 (2018-06-02 08:54:15)

Support embedded relations
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | n/a
| License       | MIT
| Doc PR        | n/a

Currently, if the returned JSON-LD document contains [embedded relations](https://api-platform.com/docs/core/serialization/#embedding-relations), Admin isn't able to deal with it an throws an errors.

This PR let Admin find the `@id` of the nested document to retrieve it.


Patch:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 945322b..78f3a13 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,7 +36,7 @@ To install the source version of API Platform Admin in your project and contribu
     $ cd admin
     $ yarn link
     $ cd ../yourpoject
-    $ yarn link "@api-platform/admin"
+    $ yarn link api-platform-admin
     # Use the React version of your project to build API Platform admin
     $ cd node_modules/react
     $ yarn link
diff --git a/src/hydra/hydraClient.js b/src/hydra/hydraClient.js
index e068f8d..25ee704 100644
--- a/src/hydra/hydraClient.js
+++ b/src/hydra/hydraClient.js
@@ -11,43 +11,89 @@ import isPlainObject from 'lodash.isplainobject';
 import qs from 'qs';
 import fetchHydra from './fetchHydra';
 
+class AORDocument {
+  constructor(obj) {
+    Object.assign(this, obj, {
+      originId: obj.id,
+      id: obj['@id'],
+    });
+  }
+
+  /**
+   * @return {string}
+   */
+  toString() {
+    return `[object ${this.id}]`;
+  }
+}
+
 /**
- * Transforms a JSON-LD document to an admin-on-rest compatible document
+ * Local cache containing embedded documents.
+ * It will be used to prevent useless extra HTTP query if the relation is displayed.
  *
- * @param {number} maxDepth
- * @param {number} depth
+ * @type {Map}
+ */
+const aorDocumentsCache = new Map();
+
+/**
+ * Transforms a JSON-LD document to an admin-on-rest compatible document.
+ *
+ * @param {Object} document
+ * @param {bool} clone
+ *
+ * @return {AORDocument}
  */
 export const transformJsonLdDocumentToAORDocument = (
-  maxDepth = 2,
-  depth = 1,
-) => documents => {
-  if (!isPlainObject(documents) && !Array.isArray(documents)) {
-    return documents;
+  document,
+  clone = true,
+  addToCache = true,
+) => {
+  if (clone) {
+    // deep clone documents
+    document = JSON.parse(JSON.stringify(document));
   }
 
-  documents = Array.isArray(documents)
-    ? Array.from(documents)
-    : Object.assign({}, documents, {
-        originId: documents.id,
-        id: documents['@id'],
-      });
+  // The main document is a JSON-LD document, convert it and store it in the cache
+  if (document['@id']) {
+    document = new AORDocument(document);
+  }
 
-  if (depth < maxDepth) {
-    if (Array.isArray(documents)) {
-      documents = documents.map(document =>
-        transformJsonLdDocumentToAORDocument(maxDepth, depth + 1)(document),
-      );
-    } else {
-      Object.keys(documents).forEach(key => {
-        documents[key] = transformJsonLdDocumentToAORDocument(
-          maxDepth,
-          depth + 1,
-        )(documents[key]);
+  // Replace embedded objects by their IRIs, and store the object itself in the cache to reuse without issuing new HTTP requests.
+  Object.keys(document).forEach(key => {
+    // to-one
+    if (isPlainObject(document[key]) && document[key]['@id']) {
+      if (addToCache) {
+        aorDocumentsCache[
+          document[key]['@id']
+        ] = transformJsonLdDocumentToAORDocument(document[key], false, false);
+      }
+      document[key] = document[key]['@id'];
+
+      return;
+    }
+
+    // to-many
+    if (
+      Array.isArray(document[key]) &&
+      document[key].length &&
+      isPlainObject(document[key][0]) &&
+      document[key][0]['@id']
+    ) {
+      document[key] = document[key].map(obj => {
+        if (addToCache) {
+          aorDocumentsCache[obj['@id']] = transformJsonLdDocumentToAORDocument(
+            obj,
+            false,
+            false,
+          );
+        }
+
+        return obj['@id'];
       });
     }
-  }
+  });
 
-  return documents;
+  return document;
 };
 
 /**
@@ -218,7 +264,7 @@ export default ({entrypoint, resources = []}, httpClient = fetchHydra) => {
         // TODO: support other prefixes than "hydra:"
         return Promise.resolve(
           response.json['hydra:member'].map(
-            transformJsonLdDocumentToAORDocument(),
+            transformJsonLdDocumentToAORDocument,
           ),
         )
           .then(data =>
@@ -230,7 +276,7 @@ export default ({entrypoint, resources = []}, httpClient = fetchHydra) => {
 
       default:
         return Promise.resolve(
-          transformJsonLdDocumentToAORDocument()(response.json),
+          transformJsonLdDocumentToAORDocument(response.json),
         )
           .then(data => convertHydraDataToAORData(resource, data))
           .then(data => ({data}));
@@ -248,7 +294,12 @@ export default ({entrypoint, resources = []}, httpClient = fetchHydra) => {
     // Hydra doesn't handle WHERE IN requests, so we fallback to calling GET_ONE n times instead
     if (GET_MANY === type) {
       return Promise.all(
-        params.ids.map(id => fetchApi(GET_ONE, resource, {id})),
+        params.ids.map(
+          id =>
+            aorDocumentsCache[id]
+              ? Promise.resolve({data: aorDocumentsCache[id]})
+              : fetchApi(GET_ONE, resource, {id}),
+        ),
       ).then(responses => ({data: responses.map(({data}) => data)}));
     }
 


Test patch:
diff --git a/src/hydra/hydraClient.test.js b/src/hydra/hydraClient.test.js
index ed498b7..e9ac553 100644
--- a/src/hydra/hydraClient.test.js
+++ b/src/hydra/hydraClient.test.js
@@ -34,43 +34,37 @@ describe('map a json-ld document to an admin on rest compatible document', () =>
         dateCreated: '2017-04-27T00:00:00+00:00',
       },
     ],
+    aNestedObject: {
+      foo: 'bar',
+    },
   };
 
-  describe('transform only the main document when called with a max depth of 1', () => {
-    const AORDocument = transformJsonLdDocumentToAORDocument(1)(jsonLdDocument);
+  describe('transform the JSON-LD document in AOR document', () => {
+    const AORDocument = transformJsonLdDocumentToAORDocument(jsonLdDocument);
+
+    test('deep clone the original object', () => {
+      expect(AORDocument).not.toBe(jsonLdDocument);
+      expect(AORDocument.aNestedObject).not.toBe(jsonLdDocument.aNestedObject);
+    });
 
     test('add an id property equal to the original @id property', () => {
-      expect(AORDocument.id).toEqual(jsonLdDocument['@id']);
+      expect(AORDocument.id).toBe(jsonLdDocument['@id']);
     });
 
     test('preserve the previous id property value in a new originId property', () => {
-      expect(AORDocument.originId).toEqual(jsonLdDocument.id);
+      expect(AORDocument.originId).toBe(jsonLdDocument.id);
     });
 
-    test('do not alter the embedded document', () => {
-      expect(AORDocument.itemReviewed.id).toEqual(
-        jsonLdDocument.itemReviewed.id,
-      );
+    test('an AOR has a custom toString method', () => {
+      expect(AORDocument.toString()).toBe('[object /reviews/327]');
     });
-  });
 
-  describe('transform the embedded document when called with a max depth of 2', () => {
-    const AORDocument = transformJsonLdDocumentToAORDocument(2)(jsonLdDocument);
-
-    test('add an id property on the embedded document equal to the @id property of the embedded document', () => {
-      expect(AORDocument.itemReviewed.id).toEqual(
-        AORDocument.itemReviewed['@id'],
-      );
+    test('transform embedded documents to their IRIs', () => {
+      expect(AORDocument.itemReviewed).toBe('/books/2');
     });
-  });
-
-  describe('transform the embedded document collection when called with a max depth of 3', () => {
-    const AORDocument = transformJsonLdDocumentToAORDocument(3)(jsonLdDocument);
 
-    test('add an id property on each document of an embedded collection equal to the @id property', () => {
-      AORDocument.comment.forEach(comment => {
-        expect(comment.id).toEqual(comment['@id']);
-      });
+    test('transform arrays of embedded documents to their IRIs', () => {
+      expect(AORDocument.comment[0]).toBe('/comments/1');
     });
   });
 });