From f934fc487275252008fd069936b0afd3cdc7bb5f Mon Sep 17 00:00:00 2001 From: Christian Zosel Date: Tue, 21 Nov 2023 15:50:24 +0100 Subject: [PATCH] feat(api): upload files directly via alexandria API BREAKING CHANGE: Requires alexandria backend v3.0.0-beta.3 --- addon/adapters/file.js | 21 +++++++++++++ addon/models/file.js | 3 +- addon/serializers/file.js | 31 +++++++++++++++++++ addon/services/alexandria-documents.js | 26 ++-------------- tests/acceptance/documents-test.js | 7 +++-- tests/dummy/app/adapters/file.js | 21 +++++++++++++ tests/dummy/mirage/config.js | 6 ++-- tests/unit/adapters/file-test.js | 12 +++++++ tests/unit/serializers/file-test.js | 11 +++++-- .../services/alexandria-documents-test.js | 9 ++---- 10 files changed, 106 insertions(+), 41 deletions(-) create mode 100644 addon/adapters/file.js create mode 100644 addon/serializers/file.js create mode 100644 tests/dummy/app/adapters/file.js create mode 100644 tests/unit/adapters/file-test.js diff --git a/addon/adapters/file.js b/addon/adapters/file.js new file mode 100644 index 00000000..bedb5544 --- /dev/null +++ b/addon/adapters/file.js @@ -0,0 +1,21 @@ +import ApplicationAdapter from "./application"; + +export default class FileAdapter extends ApplicationAdapter { + ajaxOptions(url, type, options) { + const ajaxOptions = super.ajaxOptions(url, type, options); + + if (type === "PUT") { + // Use PATCH instead of PUT for updating records + ajaxOptions.type = "PATCH"; + ajaxOptions.method = "PATCH"; + } + + if (type === "PUT" || type === "POST") { + // Remove content type for updating and creating records so the content + // type will be defined by the passed form data + delete ajaxOptions.headers["content-type"]; + } + + return ajaxOptions; + } +} diff --git a/addon/models/file.js b/addon/models/file.js index 5d5d5ff2..b6f31e48 100644 --- a/addon/models/file.js +++ b/addon/models/file.js @@ -3,10 +3,9 @@ import Model, { attr, belongsTo, hasMany } from "@ember-data/model"; export default class FileModel extends Model { @attr variant; @attr name; - @attr uploadUrl; @attr downloadUrl; - @attr objectName; @attr metainfo; + @attr content; @attr checksum; @attr createdAt; diff --git a/addon/serializers/file.js b/addon/serializers/file.js new file mode 100644 index 00000000..5ffd047b --- /dev/null +++ b/addon/serializers/file.js @@ -0,0 +1,31 @@ +import JSONSerializer from "@ember-data/serializer/json-api"; + +/* + * If pagination is enabled in the backend, the response format will be changed. + * The response data will be wrapped in a `results` object. + * This would need some configurable normalizer functionality to work. + */ +export default class FileSerializer extends JSONSerializer { + // If we don't do this, Ember will interpret the `meta` property in the single + // response as meta object and omit it from the attributes. + extractMeta() {} + + // Disable root key serialization since we want to send plain form data + serializeIntoHash = null; + + serialize(snapshot) { + const { name, variant, content } = snapshot.attributes(); + + const formData = new FormData(); + + formData.append("name", name); + formData.append("variant", variant); + formData.append("document", snapshot.belongsTo("document")?.id); + + if (content instanceof File) { + formData.append("content", content); + } + + return formData; + } +} diff --git a/addon/services/alexandria-documents.js b/addon/services/alexandria-documents.js index 0261075f..98f2b695 100644 --- a/addon/services/alexandria-documents.js +++ b/addon/services/alexandria-documents.js @@ -1,7 +1,6 @@ import { action } from "@ember/object"; import Service, { inject as service } from "@ember/service"; import { tracked } from "@glimmer/tracking"; -import fetch from "fetch"; export default class AlexandriaDocumentsService extends Service { @service store; @@ -68,19 +67,10 @@ export default class AlexandriaDocumentsService extends Service { document: documentModel, createdByGroup: this.config.activeGroup, modifiedByGroup: this.config.activeGroup, + content: file, }); await fileModel.save(); - const response = await fetch(fileModel.uploadUrl, { - method: "PUT", - body: file, - headers: { "content-type": "application/octet-stream" }, - }); - - if (!response.ok) { - throw new Error(response.statusText, response.status); - } - return documentModel; }), ); @@ -99,21 +89,9 @@ export default class AlexandriaDocumentsService extends Service { document, createdByGroup: this.config.activeGroup, modifiedByGroup: this.config.activeGroup, + content: file, }); - await fileModel.save(); - - const response = await fetch(fileModel.uploadUrl, { - method: "PUT", - body: file, - headers: { "content-type": "application/octet-stream" }, - }); - - if (!response.ok) { - throw new Error(response.statusText, response.status); - } - - await document.reload(); } /** diff --git a/tests/acceptance/documents-test.js b/tests/acceptance/documents-test.js index b38a4dff..a92fbbe7 100644 --- a/tests/acceptance/documents-test.js +++ b/tests/acceptance/documents-test.js @@ -170,9 +170,10 @@ module("Acceptance | documents", function (hooks) { assert.dom("[data-test-file]").doesNotExist(); this.assertRequest("POST", "/api/v1/files", (request) => { - const { attributes } = JSON.parse(request.requestBody).data; - assert.strictEqual(attributes.name, "test-file.txt"); - assert.strictEqual(attributes.variant, "original"); + const name = request.requestBody.get("name"); + const variant = request.requestBody.get("variant"); + assert.strictEqual(name, "test-file.txt"); + assert.strictEqual(variant, "original"); }); await triggerEvent("[data-test-replace]", "change", { files: [new File(["Ember Rules!"], "test-file.txt")], diff --git a/tests/dummy/app/adapters/file.js b/tests/dummy/app/adapters/file.js new file mode 100644 index 00000000..bedb5544 --- /dev/null +++ b/tests/dummy/app/adapters/file.js @@ -0,0 +1,21 @@ +import ApplicationAdapter from "./application"; + +export default class FileAdapter extends ApplicationAdapter { + ajaxOptions(url, type, options) { + const ajaxOptions = super.ajaxOptions(url, type, options); + + if (type === "PUT") { + // Use PATCH instead of PUT for updating records + ajaxOptions.type = "PATCH"; + ajaxOptions.method = "PATCH"; + } + + if (type === "PUT" || type === "POST") { + // Remove content type for updating and creating records so the content + // type will be defined by the passed form data + delete ajaxOptions.headers["content-type"]; + } + + return ajaxOptions; + } +} diff --git a/tests/dummy/mirage/config.js b/tests/dummy/mirage/config.js index 9795205e..3016dc0b 100644 --- a/tests/dummy/mirage/config.js +++ b/tests/dummy/mirage/config.js @@ -14,11 +14,11 @@ export default function makeServer(config) { this.resource("tags", { except: ["delete"] }); this.resource("marks", { only: ["index"] }); - this.post("/files", function (schema) { - const attrs = this.normalizedRequestAttrs(); + this.post("/files", function (schema, request) { + const attrs = Object.fromEntries(request.requestBody.entries()); return schema.files.create({ ...attrs, - uploadUrl: "/api/v1/file-upload", + document: schema.documents.find(attrs.document), }); }); diff --git a/tests/unit/adapters/file-test.js b/tests/unit/adapters/file-test.js new file mode 100644 index 00000000..83b1daa2 --- /dev/null +++ b/tests/unit/adapters/file-test.js @@ -0,0 +1,12 @@ +import { setupTest } from "dummy/tests/helpers"; +import { module, test } from "qunit"; + +module("Unit | Adapter | file", function (hooks) { + setupTest(hooks); + + // Replace this with your real tests. + test("it exists", function (assert) { + const adapter = this.owner.lookup("adapter:file"); + assert.ok(adapter); + }); +}); diff --git a/tests/unit/serializers/file-test.js b/tests/unit/serializers/file-test.js index 7ae260a9..0b10d913 100644 --- a/tests/unit/serializers/file-test.js +++ b/tests/unit/serializers/file-test.js @@ -13,10 +13,17 @@ module("Unit | Serializer | file", function (hooks) { test("it serializes records", function (assert) { const store = this.owner.lookup("service:store"); - const record = store.createRecord("file", {}); + const file = { + name: "foo", + variant: "original", + }; + const record = store.createRecord("file", file); const serializedRecord = record.serialize(); - assert.ok(serializedRecord); + assert.deepEqual(Object.fromEntries(serializedRecord.entries()), { + ...file, + document: "undefined", + }); }); }); diff --git a/tests/unit/services/alexandria-documents-test.js b/tests/unit/services/alexandria-documents-test.js index 85dd6006..a3f5e609 100644 --- a/tests/unit/services/alexandria-documents-test.js +++ b/tests/unit/services/alexandria-documents-test.js @@ -32,7 +32,7 @@ module("Unit | Service | alexandria-documents", function (hooks) { ); // Each file generates three requests. - assert.strictEqual(requests.length, files.length * 3); + assert.strictEqual(requests.length, files.length * 2); // Files will be uploaded in parallel. So, we cannot know the order. const documentRequests = requests.filter((request) => @@ -41,13 +41,9 @@ module("Unit | Service | alexandria-documents", function (hooks) { const fileRequests = requests.filter((request) => request.url.endsWith("files"), ); - const uploadRequests = requests.filter((request) => - request.url.endsWith("file-upload"), - ); assert.strictEqual(documentRequests.length, files.length); assert.strictEqual(fileRequests.length, files.length); - assert.strictEqual(uploadRequests.length, files.length); }); test("it replaces documents", async function (assert) { @@ -66,8 +62,7 @@ module("Unit | Service | alexandria-documents", function (hooks) { (request) => !request.url.includes("documents"), ); - assert.strictEqual(requests.length, 2); + assert.strictEqual(requests.length, 1); assert.ok(requests[0].url.endsWith("files")); - assert.ok(requests[1].url.endsWith("file-upload")); }); });