Реализация прямой загрузки файлов в Ruby on Rails Active Storage из React TypeScript приложения, используя Ant Design и GraphQL API.
Проблема
С одной стороны, компонент Upload библиотеки Ant Design позволяет выбирать и загружать файлы на сервер. Обычно, использовать этот компонент очень легко: нужно всего лишь указать URL для загрузки, и Ant сделает POST запрос с прикреплённым файлом.
import { Upload, Button } from 'antd';
const Test = () => (
<Upload
name='avatar'
action='https://example.com/avatar'
>
<Button>Click to Upload</Button>
</Upload>
);
С другой стороны, Rails предлагает создавать поле для загрузки файлов в представлении:
<%= form.file_field :avatar, direct_upload: true %>
В нашем проекте мы использовали представления Rails, а вся разметка создавалась только внутри React. Поэтому нам нужно было найти другой способ для загрузки файлов.
Решение
Наше решение основано на статьях Active Storage meets GraphQL: Direct Uploads, How to Use ActiveStorage Outside of a Rails View и этом ответе со StackOverflow.
Прямая загрузка в Acitve Storage происходит в несколько этапов:
- Клиент извлекает метаданные файла
- Клиент отправляет метаданные на Сервер
- Сервер подготавливает загрузку вместе с Сервисом
- Сервер отправляет URL для загрузки и необходимые заголовки Клиенту
- Клиент загружает файл на Сервис, используя URL и заголовки, полученные с Сервера
В этом примере мы используем GraphQL, поэтому шаги 2, 3, 4 будут реализованны через GraphQL мутацию.
Серверная часть
Параметры для прямой загрузки зависят от этих метаданных файла:
- Имя файла
- Тип данных
- Контрольная сумма (подробнее о ней ниже)
- Размер файла
Мы будем передавать эти данные в мутацию, а клиент в ответ будет получать конкретные параметры для загрузки (URL и заголовки), а также идентификаторы блоба.
Мы используем
гем
graphql
для реализации GraphQL контроллера в Rails.
Как мы уже говорили раньше, у нас будет мутация, которая берёт необходимые метаданные файла и даёт данные, необходимые для загрузки:
module Mutations
class CreateDirectUpload < BaseMutation
argument :filename, String, required: true
argument :byte_size, Int, required: true
argument :checksum, String, required: true
argument :content_type, String, required: true
field :url, String, null: false
field :headers, String, 'JSON of required HTTP headers', null: false
field :blob_id, ID, null: false
field :signed_blob_id, ID, null: false
end
end
Метод
resolve
будет создавать блоб и возвращать необходимые для загрузки
данные:
module Mutations
class CreateDirectUpload < BaseMutation
def resolve(filename:, byte_size:, checksum:, content_type:)
blob = ActiveStorage::Blob.create_before_direct_upload!(
filename: filename,
byte_size: byte_size,
checksum: checksum,
content_type: content_type
)
{
url: blob.service_url_for_direct_upload,
headers: blob.service_headers_for_direct_upload.to_json,
blob_id: blob.id,
signed_blob_id: blob.signed_id
}
end
end
end
Теперь клиент сможет использовать эту мутацию чтобы подготовить прямую загрузку.
Клиентская часть
Все параметры мутации не требуют пояснений, за исключением
параметра
checksum
. Строка контрольной суммы должна вычисляться по особому
алгоритму, который доступен в
пакете
@rails/activestorage
.
Бонус! Объявления типов для TypeScript доступны
в
пакете
@types/rails__activestorage
.
import { FileChecksum } from '@rails/activestorage/src/file_checksum';
const calculateChecksum = (file: File): Promise<string> => (
new Promise((resolve, reject) => (
FileChecksum.create(file, (error, checksum) => {
if (error) {
reject(error);
return;
}
resolve(checksum);
})
))
);
Компонент Upload библиотеки Ant принимает функцию
beforeUpload
, в которой мы и будем получать параметры для загрузки с
сервера. В этом примере мы будем загружать один файл и будем
сохранять необходимые параметры в состоянии компонента чтобы
использовать их чуть позже.
import { RcFile } from 'antd/lib/upload';
class Test extends React.Component {
async beforeUpload(file: RcFile): Promise<void> {
// createDirectUploadMutation is a placeholder for your GraphQL request method
const { url, headers } = createDirectUploadMutation({
checksum: await calculateChecksum(file),
filename: file.name.
contentType: file.type,
byteSize: file.size
});
this.setState({ url, headers: JSON.parse(headers) });
}
}
Сейчас мы можем реализовать функцию, которая выполнит XHR для прямой загрузки:
import { RcCustomRequestOptions } from 'antd/lib/upload/interface';
import { BlobUpload } from '@rails/activestorage/src/blob_upload';
class Test extends React.Component {
customRequest(options: RcCustomRequestOptions): void {
const { file, action, headers } = options;
const upload = new BlobUpload({
file,
directUploadData: {
headers: headers as Record<string, string>;
url: action;
}
});
upload.xhr.addEventListener('progress', event => {
const percent = (event.loaded / event.total) * 100;
options.onProgress({ percent }, file);
});
upload.create((error: Error, response: object) => {
if (error) {
options.onError(error);
} else {
options.onSuccess(response, file);
}
});
}
}
Далее, когда у нас есть функции
beforeUpload
и
customRequest
, мы можем использовать их в хуках компонента Upload:
class Test extends React.Component {
render() {
return (
<Upload
method='put' // important!
multiple={false}
beforeUpload={(file): Promise<void> => this.beforeUpload(file)}
action={this.state.url}
customRequest={(options): void => this.customRequest(options)}
>
<Button>Click to Upload!</Button>
</Upload>
);
}
}
Не забудьтье обновить маршруты Rails. Если вы перенаправляете все запросы в React:
match '*path', to: 'react#index', via: :all
То вы можете исключить маршруты для Active Storage из этого правила:
match '*path', to: 'react#index', via: :all,
constraints: ->(req) { req.path.exclude? 'rails/active_storage' }
Вот и всё. Желаю вам счастливых прямых загрузок!