Serving Multi-file Zips with Rails

The Prob

Suppose you are have an Axlsx report served with axlsx_rails, and you need to generate it for multiple models, and serve them all in one request. You can't simply call render for each:

def download_report  
  params[:user_id].each do |user_id|
    @user = User.find user_id
    render xlsx: "user_report", filename: "#{@user.name}.xlsx"
  end
end  

Render can only be called once per request. It's results are stored in the response and returned to the browser. So there is no way this will work. We have to either return one mondo object, or change this into multiple requests. As intriquing as it is, in this post we will leave aside all the wonderful solutions that could be done with javascript to retrieve multiple requests.

To Zip or Not To Zip

The obvious answer for returning the One response is to zip up all the individual spreadsheets and return that. To get the contents of each spreadsheet we will use render_to_string. To create the zip file, lets start by storing each result in a file, compressing them, and returning the zip file:

def download_report  
  dir = "/tmp/download_report_#{$$}"
  File.mkdir dir
  params[:user_id].each do |user_id|
    @user = User.find user_id
    filename = "#{@user.username}.xlsx"
    content = render_to_string xlsx: "download_report", filename: filename
    File.open(dir+'/'+filename,'w+b') {|f| f.write content}
  end
  # compress the directory
  `zip -r "#{dir}.zip" "#{dir}"`
  send_file "#{dir}.zip", type: 'application/zip'
end  

So this works. But ... I don't know about you, but using the temp directory feels pretty hacky to me. There has to be another way! And, of course, it turns out there is. (There always is!) Enter rubyzip.

The Sol

We can avoid going to disk entirely. The trick is to use rubyzip's write_buffer to create the zip file in memory, and to rewind the buffer before sending it to the response:

def download_report  
  compressed_filestream = Zip::ZipOutputStream.write_buffer do |zos|
    params[:user_id].each do |user_id|
      @user = User.find user_id
      filename = "#{@user.username}.xlsx"
      content = render_to_string xlsx: "download_report", filename: filename
      zos.put_next_entry filename
      zos.print content
    end
  end
  compressed_filestream.rewind
  send_data compressed_filestream.read, filename: 'all_users.zip', type: 'application/zip'
end  

Now we are generating each spreadsheet in memory, storing each in a zip file buffer, and serving it straight to the browser. Much cleaner!

comments powered by Disqus