はじめに
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 に貼り付けられました。

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