Repository: api-platform/admin
Pull request #347 (2020-12-02 17:03:17)

[Hydra] Manage file upload
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tickets       | N/A
| License       | MIT
| Doc PR        | TODO

When a [FileInput](https://marmelab.com/react-admin/Inputs.html#fileinput) is used, the Hydra data provider now uses `FormData` instead of JSON.
This will allow to manage file upload without relying on a tedious decoration of the data provider.

Supersedes https://github.com/api-platform/admin/pull/246.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0adde31..5a32d42 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 2.5.0
+
+* Hydra: manage file upload (use `FormData` instead of JSON)
+
 ## 2.4.3
 
 * Humanize `ReferenceInput` and `ReferenceArrayInput` label in `InputGuesser`
diff --git a/src/hydra/dataProvider.js b/src/hydra/dataProvider.js
index 19620cf..d66ce76 100644
--- a/src/hydra/dataProvider.js
+++ b/src/hydra/dataProvider.js
@@ -124,10 +124,10 @@ export default (
   let apiSchema;
 
   /**
-   * @param {Object} resource
+   * @param {Resource} resource
    * @param {Object} data
    *
-   * @returns {Promise}
+   * @returns {Promise<Object>}
    */
   const convertReactAdminDataToHydraData = (resource, data = {}) => {
     const fieldData = [];
@@ -162,20 +162,45 @@ export default (
   };
 
   /**
-   * @param {Object} resource
+   * @param {string} resource
    * @param {Object} data
    *
    * @returns {Promise}
    */
   const transformReactAdminDataToRequestBody = (resource, data = {}) => {
-    resource = apiSchema.resources.find(({ name }) => resource === name);
-    if (undefined === resource) {
+    /** @type {Resource} */
+    const apiResource = apiSchema.resources.find(
+      ({ name }) => resource === name,
+    );
+    if (undefined === apiResource) {
       return Promise.resolve(data);
     }
 
-    return convertReactAdminDataToHydraData(resource, data).then((data) =>
-      JSON.stringify(data),
-    );
+    return convertReactAdminDataToHydraData(apiResource, data).then((data) => {
+      const values = Object.values(data);
+      const containFile = (element) =>
+        isPlainObject(element) &&
+        Object.values(element).some((value) => value instanceof File);
+
+      if (!values.some((value) => containFile(value))) {
+        return JSON.stringify(data);
+      }
+
+      // If data contains a file, use FormData instead of JSON.
+      const body = new FormData();
+      Object.entries(data).map(([key, value]) => {
+        // React-Admin FileInput format is an object containing a file.
+        if (containFile(value)) {
+          return body.append(
+            key,
+            Object.values(value).find((value) => value instanceof File),
+          );
+        }
+        return body.append(key, value);
+      });
+
+      return body;
+    });
   };
 
   /**


Test patch:
diff --git a/src/hydra/dataProvider.test.js b/src/hydra/dataProvider.test.js
index 506d70c..2ea891e 100644
--- a/src/hydra/dataProvider.test.js
+++ b/src/hydra/dataProvider.test.js
@@ -1,3 +1,4 @@
+import { Api, Field, Resource } from '@api-platform/api-doc-parser';
 import dataProviderFactory, {
   transformJsonLdDocumentToReactAdminDocument,
 } from './dataProvider';
@@ -102,7 +103,22 @@ describe('Transform a React Admin request to an Hydra request', () => {
   mockFetchHydra.mockReturnValue({
     json: { 'hydra:member': [], 'hydra:totalItems': 3 },
   });
-  const dataProvider = dataProviderFactory('entrypoint', mockFetchHydra);
+  const mockApiDocumentationParser = jest.fn(() =>
+    Promise.resolve({
+      api: new Api('entrypoint', {
+        resources: [
+          new Resource('resource', '/resources', {
+            fields: [new Field('bar')],
+          }),
+        ],
+      }),
+    }),
+  );
+  const dataProvider = dataProviderFactory(
+    'entrypoint',
+    mockFetchHydra,
+    mockApiDocumentationParser,
+  );
 
   test('React Admin get list with filter parameters and custom search params', () => {
     return dataProvider
@@ -147,4 +163,55 @@ describe('Transform a React Admin request to an Hydra request', () => {
         ]);
       });
   });
+
+  test('React Admin create', async () => {
+    await dataProvider.introspect();
+
+    return dataProvider
+      .create('resource', {
+        data: {
+          foo: 'foo',
+          bar: 'baz',
+        },
+      })
+      .then(() => {
+        const url = mockFetchHydra.mock.calls[1][0];
+        expect(url).toBeInstanceOf(URL);
+        expect(url.toString()).toEqual('http://localhost/entrypoint/resource');
+        const options = mockFetchHydra.mock.calls[1][1];
+        expect(options).toHaveProperty('method');
+        expect(options.method).toEqual('POST');
+        expect(options).toHaveProperty('body');
+        expect(options.body).toEqual('{"foo":"foo","bar":"baz"}');
+      });
+  });
+
+  test('React Admin create upload file', async () => {
+    await dataProvider.introspect();
+
+    const file = new File(['foo'], 'foo.txt');
+    return dataProvider
+      .create('resource', {
+        data: {
+          image: {
+            rawFile: file,
+          },
+          bar: 'baz',
+        },
+      })
+      .then(() => {
+        const url = mockFetchHydra.mock.calls[2][0];
+        expect(url).toBeInstanceOf(URL);
+        expect(url.toString()).toEqual('http://localhost/entrypoint/resource');
+        const options = mockFetchHydra.mock.calls[2][1];
+        expect(options).toHaveProperty('method');
+        expect(options.method).toEqual('POST');
+        expect(options).toHaveProperty('body');
+        expect(options.body).toBeInstanceOf(FormData);
+        expect(Array.from(options.body.entries())).toEqual([
+          ['image', file],
+          ['bar', 'baz'],
+        ]);
+      });
+  });
 });