NotionRubyMapping のアップデート(20) : hkob の雑記録 (179)

はじめに

hkob の雑記録の第179回目は、10MBを超えるファイルを複数に分割してアップロードする仕組みを解説します。

with a large file テスト

multipart の WebMock のテストができないため、with a small file の時には、次のようにしてresponse を body にもつ mock を返す stub で対応しました。

          allow(tc.nc.multipart_client).to receive(:send)
            .and_return(instance_double(Faraday::Response, {body: response}))

今回、10MB のファイルと残りのファイルを 2 回送信する必要があるので、and_return ではなく、and_invoke で対応しました。これにより、1 回目は response1 を返し、2回目に response2 を返すようにしました。また、先にテストした split_to_small_files も動作確認ができているので、Tempfile の mock を二つ返す stub にしました。最終的にテストは以下のようになりました。

      context "with a large file" do
        let(:fname) { "spec/fixtures/sample-15s.mp4" }
        let(:id) { TestConnection::FILE_UPLOAD_VIDEO_ID }
        let(:response1) { tc.read_json "upload_file_video1" }
        let(:response2) { tc.read_json "upload_file_video2" }

        let(:fuv) { tc.read_json "create_file_upload_video" }

        before do
          allow(tc.nc).to receive(:create_file_upload_request).and_return(fuv)
          allow(tc.nc.multipart_client).to receive(:send)
            .and_invoke(
              ->(_) { instance_double(Faraday::Response, {body: response1}) },
              ->(_) { instance_double(Faraday::Response, {body: response2}) },
            )
          allow(File).to receive(:exist?).with(fname).and_return(true)
          allow(File).to receive(:size).with(fname).and_return(11_916_526)
          allow(Faraday::Multipart::FilePart).to receive(:new).and_return(instance_double(Faraday::Multipart::FilePart))
          allow(FileUploadObject).to receive(:split_to_small_files).and_return(
            [
              instance_double(Tempfile,
                              path: "/var/folders/cw/9fjhttb17jbb3233xc3k9l3c0000gp/T/split20250622-98904-sy5cs0",
                              close: true, unlink: true),
              instance_double(Tempfile,
                              path: "/var/folders/cw/9fjhttb17jbb3233xc3k9l3c0000gp/T/split20250622-98904-xpxrhe",
                              close: true, unlink: true),
            ],
          )
        end

        subject { described_class.new(fname: fname) }

        it { expect(subject.id).to eq id }
      end

FileUploadObject のコンストラクタ

large file に対応するように FIleUploadObject のコンストラクタを以下のように修正しました。10MB の塊の個数を @number_of_parts とし、これが 1 の時にはすでにテスト済みの single_file_upload を呼ぶだけにします。10MB を超える場合には、先日作成した split_to_small_files で temp_files を作成します。ブロック番号を付与して single_file_upload を呼ぶことで、part_number も一緒に送信します。全てが送信されたら complete_a_file_upload_request を呼び出します。

class FileUploadObject
  MAX_SIZE = 10 * 1024 * 1024 # 10 MB
  # @param [String] id
  def initialize(fname:, external_url: nil)
    @fname = fname
    if external_url
      payload = {mode: "external_url", external_url: external_url, filename: fname}
      create payload
    else
      raise StandardError, "FileUploadObject requires a valid file name: #{fname}" unless File.exist?(fname)

      @file_size = File.size fname
      @number_of_parts = (@file_size - 1) / MAX_SIZE + 1
      payload = if @number_of_parts == 1
                  {}
                else
                  {number_of_parts: @number_of_parts, mode: "multi_part",
                   filename: File.basename(@fname)}
                end
      create payload
      if @number_of_parts == 1
        single_file_upload
      else
        @temp_files = FileUploadObject.split_to_small_files(@fname, MAX_SIZE)
        @temp_files.each_with_index do |temp_file, i|
          single_file_upload temp_file.path, i + 1
          temp_file.close
          temp_file.unlink
        end
        NotionRubyMapping::NotionCache.instance.complete_a_file_upload_request @id
      end
    end
  end
  attr_reader :id, :fname

single_file_upload は以下のように修正します。複数ファイルの場合には、結果が uploaded ではなく pending として返ってきます。また、part_number オプションを追加しました。

  # @param [String] fname
  # @param [Integer, NilClass] part_number
  def single_file_upload(fname = @fname, part_number = 0)
    if @number_of_parts > 1
      options = {"part_number" => part_number}
      status = "pending"
    else
      options = {}
      status = "uploaded"
    end
    nc = NotionRubyMapping::NotionCache.instance
    response = nc.file_upload_request fname, @id, options
    return if nc.hex_id(response["id"]) == @id && response["status"] == status

    raise StandardError, "File upload failed: #{response}"
  end

NotionCache の file_upload_request も受け取る変数を options に変更しています。

    # @param [String] fname
    # @param [String] id
    # @param [Hash] options
    # @return [Hash]
    def file_upload_request(fname, id, options = {})
      multipart_request(file_upload_path(id), fname, options)
    end

multipart_request も options に対応します。options があった場合には、昨日調査した ParamPart で text/plain を送付します。

    # @param [String] path
    # @param [String] fname
    # @param [Hash] options
    # @return [Hash] response hash
    def multipart_request(path, fname, options = {})
      raise "Please call `NotionRubyMapping.configure' before using other methods" unless @notion_token

      content_type = MIME::Types.type_for(fname).first.to_s

      sleep @wait
      body = options.map { |k, v| [k, Faraday::Multipart::ParamPart.new(v, "text/plain")] }.to_h
      file_part = Faraday::Multipart::FilePart.new(fname, content_type, File.basename(fname))
      response = multipart_client.send(:post) do |request|
        request.headers["Authorization"] = "Bearer #{@notion_token}"
        request.headers["content-Type"] = "multipart/form-data"
        request.path = path
        request.body = {file: file_part}.merge body
      end
      p response.body if @debug
      response.body
    end

動作確認

テストが動いているので問題ないと思いますが、実際に irb でテストしてみます。

hkob@hM1Pro ~/L/C/D/r/notion_ruby_mapping (development)> irb
irb(main):001> require_relative "lib/notion_ruby_mapping.rb"
=> true
irb(main):002> include NotionRubyMapping
=> Object
irb(main):003> NotionRubyMapping.configure { |c| c.token = ENV["NOTION_API_KEY"] }
=> "secret_XXXXXXXXXXXXXXXXXXXXXX"
irb(main):004> fuo = FileUploadObject.new(fname: "spec/fixtures/_sample-15s.mp4")
=> 
#<FileUploadObject:0x000000011ee99b60
irb(main):005> block = Block.find "21ed8e4e98ab80f1b435e4cc65917273" 
=> NotionRubyMapping::VideoBlock-21ed8e4e98ab80f1b435e4cc65917273
irb(main):006> block.file_upload_object= fuo
=> 
#<FileUploadObject:0x000000011ee99b60
...
irb(main):007> block.save
=> NotionRubyMapping::VideoBlock-21ed8e4e98ab80f1b435e4cc65917273

アップロードしたファイルが無事に VideoBlock に貼り付けられました。

貼り付けられた Video block

おわりに

Symbol を String に戻すのにかなり時間がかかってしまいました。まだ外部 URL からのアップロードが実装終わっていませんが、問題のあるバージョンを放置しておくわけにもいかないので、Version 2.0.0 として更新してしまいます。

https://hkob.notion.site/hkob-16dd8e4e98ab807cbe3cf3cc94cdfe0f?pvs=4