mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-02 20:18:08 +00:00
feat: add a common upload endpoint (#7806)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
13
app/controllers/api/v1/upload_controller.rb
Normal file
13
app/controllers/api/v1/upload_controller.rb
Normal file
@@ -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
|
||||||
@@ -9,14 +9,6 @@ class AutomationsAPI extends ApiClient {
|
|||||||
clone(automationId) {
|
clone(automationId) {
|
||||||
return axios.post(`${this.url}/${automationId}/clone`);
|
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();
|
export default new AutomationsAPI();
|
||||||
|
|||||||
@@ -47,20 +47,6 @@ class ArticlesAPI extends PortalsAPI {
|
|||||||
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
|
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 }) {
|
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
|
||||||
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
|
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
|
||||||
positions_hash: reorderedGroup,
|
positions_hash: reorderedGroup,
|
||||||
|
|||||||
@@ -61,11 +61,9 @@ export default {
|
|||||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
|
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
|
||||||
try {
|
try {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('attachment', file, file.name);
|
|
||||||
const id = await this.$store.dispatch(
|
const id = await this.$store.dispatch(
|
||||||
'automations/uploadAttachment',
|
'automations/uploadAttachment',
|
||||||
formData
|
file
|
||||||
);
|
);
|
||||||
this.$emit('input', [id]);
|
this.$emit('input', [id]);
|
||||||
this.uploadState = 'uploaded';
|
this.uploadState = 'uploaded';
|
||||||
|
|||||||
53
app/javascript/dashboard/helper/specs/uploadHelper.spec.js
Normal file
53
app/javascript/dashboard/helper/specs/uploadHelper.spec.js
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
41
app/javascript/dashboard/helper/uploadHelper.js
Normal file
41
app/javascript/dashboard/helper/uploadHelper.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||||
import types from '../mutation-types';
|
import types from '../mutation-types';
|
||||||
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import AutomationAPI from '../../api/automation';
|
import AutomationAPI from '../../api/automation';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
@@ -77,12 +78,8 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
uploadAttachment: async (_, file) => {
|
uploadAttachment: async (_, file) => {
|
||||||
try {
|
const { blobId } = await uploadFile(file);
|
||||||
const { data } = await AutomationAPI.attachment(file);
|
return blobId;
|
||||||
return data.blob_id;
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||||
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||||
|
|
||||||
import types from '../../mutation-types';
|
import types from '../../mutation-types';
|
||||||
@@ -126,19 +127,9 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
attachImage: async (_, { portalSlug, file }) => {
|
attachImage: async (_, { file }) => {
|
||||||
try {
|
const { fileUrl } = await uploadFile(file);
|
||||||
const {
|
return fileUrl;
|
||||||
data: { file_url: fileUrl },
|
|
||||||
} = await articlesAPI.uploadImage({
|
|
||||||
portalSlug,
|
|
||||||
file,
|
|
||||||
});
|
|
||||||
return fileUrl;
|
|
||||||
} catch (error) {
|
|
||||||
throwErrorMessage(error);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { actions } from '../actions';
|
import { actions } from '../actions';
|
||||||
import * as types from '../../../mutation-types';
|
import * as types from '../../../mutation-types';
|
||||||
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
|
|
||||||
|
jest.mock('dashboard/helper/uploadHelper');
|
||||||
|
|
||||||
const articleList = [
|
const articleList = [
|
||||||
{
|
{
|
||||||
id: 1,
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ Rails.application.routes.draw do
|
|||||||
# end of account scoped api routes
|
# end of account scoped api routes
|
||||||
# ----------------------------------
|
# ----------------------------------
|
||||||
|
|
||||||
|
resources :upload, only: [:create]
|
||||||
|
|
||||||
namespace :integrations do
|
namespace :integrations do
|
||||||
resources :webhooks, only: [:create]
|
resources :webhooks, only: [:create]
|
||||||
end
|
end
|
||||||
|
|||||||
42
spec/controllers/api/v1/upload_controller_spec.rb
Normal file
42
spec/controllers/api/v1/upload_controller_spec.rb
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user