From deec1d213b380b6970ceb4496adf49b72fd4c59f Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 31 Aug 2023 10:36:02 +0700 Subject: [PATCH] feat: add a common upload endpoint (#7806) Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> --- app/controllers/api/v1/upload_controller.rb | 13 +++++ app/javascript/dashboard/api/automation.js | 8 --- .../dashboard/api/helpCenter/articles.js | 14 ----- .../widgets/AutomationFileInput.vue | 4 +- .../helper/specs/uploadHelper.spec.js | 53 +++++++++++++++++++ .../dashboard/helper/uploadHelper.js | 41 ++++++++++++++ .../dashboard/store/modules/automations.js | 9 ++-- .../modules/helpCenterArticles/actions.js | 17 ++---- .../helpCenterArticles/specs/action.spec.js | 36 +++++++++++++ config/routes.rb | 2 + .../api/v1/upload_controller_spec.rb | 42 +++++++++++++++ 11 files changed, 195 insertions(+), 44 deletions(-) create mode 100644 app/controllers/api/v1/upload_controller.rb create mode 100644 app/javascript/dashboard/helper/specs/uploadHelper.spec.js create mode 100644 app/javascript/dashboard/helper/uploadHelper.js create mode 100644 spec/controllers/api/v1/upload_controller_spec.rb diff --git a/app/controllers/api/v1/upload_controller.rb b/app/controllers/api/v1/upload_controller.rb new file mode 100644 index 000000000..ad6b0b96c --- /dev/null +++ b/app/controllers/api/v1/upload_controller.rb @@ -0,0 +1,13 @@ +class Api::V1::UploadController < Api::BaseController + def create + file_blob = ActiveStorage::Blob.create_and_upload!( + key: nil, + io: params[:attachment].tempfile, + filename: params[:attachment].original_filename, + content_type: params[:attachment].content_type + ) + file_blob.save! + + render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id } + end +end diff --git a/app/javascript/dashboard/api/automation.js b/app/javascript/dashboard/api/automation.js index eef39d12c..e83ece3d1 100644 --- a/app/javascript/dashboard/api/automation.js +++ b/app/javascript/dashboard/api/automation.js @@ -9,14 +9,6 @@ class AutomationsAPI extends ApiClient { clone(automationId) { return axios.post(`${this.url}/${automationId}/clone`); } - - attachment(file) { - return axios.post(`${this.url}/attach_file`, file, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - } } export default new AutomationsAPI(); diff --git a/app/javascript/dashboard/api/helpCenter/articles.js b/app/javascript/dashboard/api/helpCenter/articles.js index 5423b1de2..fb21a0847 100644 --- a/app/javascript/dashboard/api/helpCenter/articles.js +++ b/app/javascript/dashboard/api/helpCenter/articles.js @@ -47,20 +47,6 @@ class ArticlesAPI extends PortalsAPI { return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`); } - uploadImage({ portalSlug, file }) { - let formData = new FormData(); - formData.append('background_image', file); - return axios.post( - `${this.url}/${portalSlug}/articles/attach_file`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - } - reorderArticles({ portalSlug, reorderedGroup, categorySlug }) { return axios.post(`${this.url}/${portalSlug}/articles/reorder`, { positions_hash: reorderedGroup, diff --git a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue index b2fb1004a..023c3dba3 100644 --- a/app/javascript/dashboard/components/widgets/AutomationFileInput.vue +++ b/app/javascript/dashboard/components/widgets/AutomationFileInput.vue @@ -61,11 +61,9 @@ export default { this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING'); try { const file = event.target.files[0]; - const formData = new FormData(); - formData.append('attachment', file, file.name); const id = await this.$store.dispatch( 'automations/uploadAttachment', - formData + file ); this.$emit('input', [id]); this.uploadState = 'uploaded'; diff --git a/app/javascript/dashboard/helper/specs/uploadHelper.spec.js b/app/javascript/dashboard/helper/specs/uploadHelper.spec.js new file mode 100644 index 000000000..11bb535f0 --- /dev/null +++ b/app/javascript/dashboard/helper/specs/uploadHelper.spec.js @@ -0,0 +1,53 @@ +import { uploadFile } from '../uploadHelper'; +import axios from 'axios'; + +// Mocking axios using jest-mock-axios +global.axios = axios; +jest.mock('axios'); + +describe('#Upload Helpers', () => { + afterEach(() => { + // Cleaning up the mock after each test + axios.post.mockReset(); + }); + + it('should send a POST request with correct data', async () => { + const mockFile = new File(['dummy content'], 'example.png', { + type: 'image/png', + }); + const mockResponse = { + data: { + file_url: 'https://example.com/fileUrl', + blob_key: 'blobKey123', + blob_id: 'blobId456', + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + + const result = await uploadFile(mockFile); + + expect(axios.post).toHaveBeenCalledWith( + '/api/v1/upload', + expect.any(FormData), + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + + expect(result).toEqual({ + fileUrl: 'https://example.com/fileUrl', + blobKey: 'blobKey123', + blobId: 'blobId456', + }); + }); + + it('should handle errors', async () => { + const mockFile = new File(['dummy content'], 'example.png', { + type: 'image/png', + }); + const mockError = new Error('Failed to upload'); + + axios.post.mockRejectedValueOnce(mockError); + + await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload'); + }); +}); diff --git a/app/javascript/dashboard/helper/uploadHelper.js b/app/javascript/dashboard/helper/uploadHelper.js new file mode 100644 index 000000000..d2baa9e08 --- /dev/null +++ b/app/javascript/dashboard/helper/uploadHelper.js @@ -0,0 +1,41 @@ +/* global axios */ + +/** + * Constants and Configuration + */ + +// Version for the API endpoint. +const API_VERSION = 'v1'; + +// Default headers to be used in the axios request. +const HEADERS = { + 'Content-Type': 'multipart/form-data', +}; + +/** + * Uploads a file to the server. + * + * This function sends a POST request to a given API endpoint and uploads the specified file. + * The function uses FormData to wrap the file and axios to send the request. + * + * @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element). + * @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error. + */ +export async function uploadFile(file) { + // Create a new FormData instance. + let formData = new FormData(); + + // Append the file to the FormData instance under the key 'attachment'. + formData.append('attachment', file); + + // Use axios to send a POST request to the upload endpoint. + const { data } = await axios.post(`/api/${API_VERSION}/upload`, formData, { + headers: HEADERS, + }); + + return { + fileUrl: data.file_url, + blobKey: data.blob_key, + blobId: data.blob_id, + }; +} diff --git a/app/javascript/dashboard/store/modules/automations.js b/app/javascript/dashboard/store/modules/automations.js index 7d9f4cb69..df3bf6f6a 100644 --- a/app/javascript/dashboard/store/modules/automations.js +++ b/app/javascript/dashboard/store/modules/automations.js @@ -1,5 +1,6 @@ import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers'; import types from '../mutation-types'; +import { uploadFile } from 'dashboard/helper/uploadHelper'; import AutomationAPI from '../../api/automation'; export const state = { @@ -77,12 +78,8 @@ export const actions = { } }, uploadAttachment: async (_, file) => { - try { - const { data } = await AutomationAPI.attachment(file); - return data.blob_id; - } catch (error) { - throw new Error(error); - } + const { blobId } = await uploadFile(file); + return blobId; }, }; diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js index df4d6a462..39aefafc0 100644 --- a/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js +++ b/app/javascript/dashboard/store/modules/helpCenterArticles/actions.js @@ -1,4 +1,5 @@ import articlesAPI from 'dashboard/api/helpCenter/articles'; +import { uploadFile } from 'dashboard/helper/uploadHelper'; import { throwErrorMessage } from 'dashboard/store/utils/api'; import types from '../../mutation-types'; @@ -126,19 +127,9 @@ export const actions = { } }, - attachImage: async (_, { portalSlug, file }) => { - try { - const { - data: { file_url: fileUrl }, - } = await articlesAPI.uploadImage({ - portalSlug, - file, - }); - return fileUrl; - } catch (error) { - throwErrorMessage(error); - } - return ''; + attachImage: async (_, { file }) => { + const { fileUrl } = await uploadFile(file); + return fileUrl; }, reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => { diff --git a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js index 6fdfc5389..378c4809d 100644 --- a/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js +++ b/app/javascript/dashboard/store/modules/helpCenterArticles/specs/action.spec.js @@ -1,6 +1,10 @@ import axios from 'axios'; import { actions } from '../actions'; import * as types from '../../../mutation-types'; +import { uploadFile } from 'dashboard/helper/uploadHelper'; + +jest.mock('dashboard/helper/uploadHelper'); + const articleList = [ { id: 1, @@ -180,4 +184,36 @@ describe('#actions', () => { ]); }); }); + + describe('attachImage', () => { + it('should upload the file and return the fileUrl', async () => { + // Given + const mockFile = new Blob(['test'], { type: 'image/png' }); + mockFile.name = 'test.png'; + + const mockFileUrl = 'https://test.com/test.png'; + uploadFile.mockResolvedValueOnce({ fileUrl: mockFileUrl }); + + // When + const result = await actions.attachImage({}, { file: mockFile }); + + // Then + expect(uploadFile).toHaveBeenCalledWith(mockFile); + expect(result).toBe(mockFileUrl); + }); + + it('should throw an error if the upload fails', async () => { + // Given + const mockFile = new Blob(['test'], { type: 'image/png' }); + mockFile.name = 'test.png'; + + const mockError = new Error('Upload failed'); + uploadFile.mockRejectedValueOnce(mockError); + + // When & Then + await expect(actions.attachImage({}, { file: mockFile })).rejects.toThrow( + 'Upload failed' + ); + }); + }); }); diff --git a/config/routes.rb b/config/routes.rb index d851c7adc..19550b225 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,6 +226,8 @@ Rails.application.routes.draw do # end of account scoped api routes # ---------------------------------- + resources :upload, only: [:create] + namespace :integrations do resources :webhooks, only: [:create] end diff --git a/spec/controllers/api/v1/upload_controller_spec.rb b/spec/controllers/api/v1/upload_controller_spec.rb new file mode 100644 index 000000000..279257dc3 --- /dev/null +++ b/spec/controllers/api/v1/upload_controller_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::UploadController', type: :request do + describe 'POST /api/v1/upload/' do + let(:account) { create(:account) } + let(:user) { create(:user, account: account) } + + it 'uploads the image when authorized' do + file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + + post '/api/v1/upload/', + headers: user.create_new_auth_token, + params: { attachment: file } + + expect(response).to have_http_status(:success) + blob = response.parsed_body + + expect(blob['errors']).to be_nil + + expect(blob['file_url']).to be_present + expect(blob['blob_key']).to be_present + expect(blob['blob_id']).to be_present + end + + it 'does not upload when un-authorized' do + file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') + + post '/api/v1/upload/', + headers: {}, + params: { attachment: file } + + expect(response).to have_http_status(:unauthorized) + blob = response.parsed_body + + expect(blob['errors']).to be_present + + expect(blob['file_url']).to be_nil + expect(blob['blob_key']).to be_nil + expect(blob['blob_id']).to be_nil + end + end +end