Личный сайт Мустафина Ильгиза

Прямая загрузка файлов в Rails React Ant через GraphQL

Последнее обновление:

Реализация прямой загрузки файлов в 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 происходит в несколько этапов:

  1. Клиент извлекает метаданные файла
  2. Клиент отправляет метаданные на Сервер
  3. Сервер подготавливает загрузку вместе с Сервисом
  4. Сервер отправляет URL для загрузки и необходимые заголовки Клиенту
  5. Клиент загружает файл на Сервис, используя 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' }

Вот и всё. Желаю вам счастливых прямых загрузок!