{"id":17681285,"url":"https://github.com/madeindjs/zip_example","last_synced_at":"2025-03-12T14:31:06.547Z","repository":{"id":96109300,"uuid":"159820664","full_name":"madeindjs/zip_example","owner":"madeindjs","description":null,"archived":true,"fork":false,"pushed_at":"2018-12-01T18:00:13.000Z","size":46,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":3,"default_branch":"master","last_synced_at":"2025-02-05T21:41:40.565Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/madeindjs.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2018-11-30T12:30:10.000Z","updated_at":"2023-06-29T19:59:18.000Z","dependencies_parsed_at":null,"dependency_job_id":"b7829ff4-313c-4f99-ab1e-9f6709c9319c","html_url":"https://github.com/madeindjs/zip_example","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/madeindjs%2Fzip_example","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/madeindjs%2Fzip_example/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/madeindjs%2Fzip_example/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/madeindjs%2Fzip_example/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/madeindjs","download_url":"https://codeload.github.com/madeindjs/zip_example/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":243234456,"owners_count":20258470,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2024-10-24T09:10:43.698Z","updated_at":"2025-03-12T14:31:06.519Z","avatar_url":"https://github.com/madeindjs.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"Récemment, pour mon projet [iSignif.fr](https://isignif.fr), j'ai voulu implémenter une fonctionnalité qui permet de **télécharger une archive** `.zip` de plusieurs fichiers. Rien de bien compliqué sauf que j'utilise [**ActiveStorage**][active_storage_guide]. Active Storage fait partie des des nouvelles fonctionnalités de Rails 5.2 (sorti en janvier 2018) qui permet d'**attacher** un fichier à un modèle en utilisant **divers services de stockage** tels que [Amazon S3](https://aws.amazon.com/fr/s3/), [Google Cloud Storage](https://cloud.google.com/storage/) ou [Microsoft Azure Storage](https://azure.microsoft.com/en-us/services/storage/).\n\nCela présente beaucoup d'avantages car les fichiers sont **séparés** du serveur web. Ils sont stockés sur des services qui sont **spécialisés** dans le stockage des fichiers. Le problème est que, lorsqu'on veut les manipuler, ils ne sont pas présents physiquement sur le serveur web.\n\nVu que la documentation est assez pauvre là dessus (car c'est une fonctionnalité récente), j'ai décidé d'écrire un article.\n\nDans cet article nous allons:\n\n- rédiger des tests qui correspondent au fonctionnement attendu\n- implémenter le code pour passer les tests\n- factoriser et améliorer l'implémentation\n- exporter le tout dans une librairie\n\n**TLDR**: Passé la complexité de l'implémentation du code, il est très facile de déplacer le code dans des méthodes réutilisables en utilisant les [`ActiveSupport::Concern`][concerns_api].\n\n## Création d'un exemple\n\n### Génération du projet\n\nPour ce tutoriel j'ai choisi de partir d'un nouveau projet. Créons donc un nouveau projet Rails:\n\n~~~bash\n$ rails new zip_example --skip-action-cable --skip-coffee --skip-turbolinks --skip-system-test --skip-action-mailer\n~~~\n\n\u003e j'ai ajouté \"quelques\" *flags* `--skip` afin d'enlever tout ce qui nous sera inutile\n\nOn va aussi générer aussi une entité `User` avec la commande `scaffold`:\n\n~~~bash\n$ rails g scaffold user name:string\n~~~\n\n\u003e La commande `scaffold` va s'occuper de créer le *controller*, le *model*, les *views* et même la migration\n\nMaintenant puisque je veux utiliser *Active Storage*, j'ai besoin de l'**installer**. C'est très facile, la commande suivante le fait pour nous:\n\n~~~bash\n$ rails active_storage:install\n~~~\n\n\u003e Cette commande génère juste une migration qui va créer les tables `active_storage_blobs` \u0026 `active_storage_attachments`\n\nMaintenant que toutes nos **migrations** sont créées, il suffit de les jouer:\n\n~~~bash\n$ rake db:migrate\n~~~\n\nVoilà, nous somme prêts à coder!\n\n### Ajout de l'Active Storage\n\nPour attacher un(des) fichier(s) à un modèles, il suffit d'ajouter **une seule ligne** à notre modèle `User`. C'est là toute la beauté de *conventions over configuration*!\n\n~~~ruby\n# app/models/user.rb\nclass User \u003c ApplicationRecord\n  has_many_attached :pictures\nend\n~~~\n\n\u003e Chaque `ActFile` possède un fichier (`has_one_attached :file`) qui représente donc une liaison vers un objet [`ActiveStorage::Attached::Many`][active_storage_attached_many].\n\nJe vais aussi ajouter un champ `file_field :pictures` au **formulaire** pour qu'on puisse charger nos fichiers\n\n~~~erb\n\u003c!-- app/views/users/_form.html.erb --\u003e\n\u003c%= form_with(model: user, local: true) do |form| %\u003e\n  \u003c!-- ... --\u003e\n  \u003c%= form.label :name %\u003e\n  \u003c%= form.text_field :name %\u003e\n  \u003c%= form.file_field :pictures, multiple: true, class: 'form-control' %\u003e\n  \u003c%= form.submit %\u003e\n\u003c% end %\u003e\n~~~\n\nOn n'oublie pas d'**autoriser** ce champs dans le *controller*:\n\n~~~ruby\n# app/controllers/users_controller.rb\nclass UsersController \u003c ApplicationController\n  # ....\n\n  private\n\n  # Use callbacks to share common setup or constraints between actions.\n  def set_user\n    @user = User.find(params[:id])\n  end\nend\n~~~\n\n\nOn démarre maintenant le serveur avec `rails server` et on se rend à l'URL `http://localhost:3000/users/new` pour créer un utilisateur. Lorsqu'on valide le formulaire avec des fichiers, on voit dans la console du serveur que les fichiers sont **chargés**:\n\n~~~\nStarted POST \"/users\" for 127.0.0.1 at 2018-11-30 08:48:29 +0100\nProcessing by UsersController#create as HTML\n  ActiveStorage::Blob Create (1.0ms)  INSERT INTO \"active_storage_blobs\" (\"key\", \"filename\", \"content_type\", \"metadata\", \"byte_size\", \"checksum\", \"created_at\") VALUES (?, ?, ?, ?, ?, ?, ?)  [[\"key\", \"2gVacD6hhv6viMW2bgYGVzsV\"], [\"filename\", \"2172652.png\"], [\"content_type\", \"image/png\"], [\"metadata\", \"{\\\"identified\\\":true}\"], [\"byte_size\", 414730], [\"checksum\", \"L2ka9VIXeONlrtvE8w0kMQ==\"], [\"created_at\", \"2018-11-30 07:48:29.724333\"]]\n  ActiveStorage::Blob Create (0.4ms)  INSERT INTO \"active_storage_blobs\" (\"key\", \"filename\", \"content_type\", \"metadata\", \"byte_size\", \"checksum\", \"created_at\") VALUES (?, ?, ?, ?, ?, ?, ?)  [[\"key\", \"z1JQEeVUx9Nbe7cndx5ZN1dh\"], [\"filename\", \"b64ae90.jpg\"], [\"content_type\", \"image/jpeg\"], [\"metadata\", \"{\\\"identified\\\":true}\"], [\"byte_size\", 403558], [\"checksum\", \"rBfrYgoJn0T5ZMsy4e9vSg==\"], [\"created_at\", \"2018-11-30 07:48:29.756230\"]]\n  ActiveStorage::Attachment Create (0.4ms)  INSERT INTO \"active_storage_attachments\" (\"name\", \"record_type\", \"record_id\", \"blob_id\", \"created_at\") VALUES (?, ?, ?, ?, ?)  [[\"name\", \"pictures\"], [\"record_type\", \"User\"], [\"record_id\", 2], [\"blob_id\", 3], [\"created_at\", \"2018-11-30 07:48:29.774326\"]]\n  ActiveStorage::Attachment Create (0.2ms)  INSERT INTO \"active_storage_attachments\" (\"name\", \"record_type\", \"record_id\", \"blob_id\", \"created_at\") VALUES (?, ?, ?, ?, ?)  [[\"name\", \"pictures\"], [\"record_type\", \"User\"], [\"record_id\", 2], [\"blob_id\", 4], [\"created_at\", \"2018-11-30 07:48:29.777281\"]]\nCompleted 302 Found in 96ms (ActiveRecord: 37.5ms)\n~~~\n\n## Création du ZIP\n\nL'idée serait donc de créer une route `http://localhost:3000/users/1.zip` qui nous permettrait d'obtenir une archive contenant tous les fichiers liés à l'utilisateur.\n\n### Création du test\n\nComme toujours, on essaie de créer un test qui **échoue** dans un premier temps ([*Test Driven Development*][tdd]). J'ai simplement choisi de créer un *test controller* et de **tester la réponse** de la requête. C'est très simple, mais ça marche:\n\n~~~ruby\n# test/controllers/users_controller_test.rb\nrequire 'test_helper'\n\nclass UsersControllerTest \u003c ActionDispatch::IntegrationTest\n\n  # ...\n\n  test 'should get user as zip' do\n    get user_url(@user, format: :zip)\n    assert_response :success\n    assert_equal 'application/zip', response.content_type\n  end\nend\n~~~\n\nPour l'instant, le test échoue et **c'est normal**:\n\n~~~\n$ rake test\n\n# Running:\n\n.......E\n\nError:\nUsersControllerTest#test_should_get_user_as_zip:\nActionController::UnknownFormat: UsersController#show is missing a template for this request format and variant.\n\nrequest.formats: [\"application/zip\"]\n~~~\n\n### Implémentation\n\nDans un premier temps, il est nécessaire de **télécharger** les fichiers sur le serveur. Pour cela, nous allons:\n\n1. **Créer** un dossier temporaire\n2. **Télécharger** le contenu des fichiers avec [`ActiveStorage::Blob#download`][active_storage_blob_download]\n3. **Zipper** les fichier dans le dossier temporaire avec le contenu que je viens de récupérer\n4. Renvoyer le contenu du fichier zip\n\nVu qu'on parle de zip, nous allons utiliser la gem [rubyzip][rubyzip]. On modifie donc le *Gemfile*:\n\n~~~ruby\n# Gemfile\ngem 'rubyzip', '\u003e= 1.0.0'\n~~~\n\nOn installe avec `bundle install` et on démarre le serveur avec `rails s`. Nous sommes prêts à coder!\n\nComme je le disais plus haut, le problème est qu'il faut **récupérer** les fichiers sur le serveur. Nous aurions pu choisir de mettre le contenu du fichier en mémoire vive mais nous ne connaissons pas la taille des fichiers donc je préfère les stocker temporairement sur le disque dur.\n\n~~~ruby\n# app/controllers/users_controller.rb\n\n# Download active storage files on server in a temporary folder\n#\n# @param files [ActiveStorage::Attached::Many] files to save\n# @return [Array\u003cString\u003e] files paths of saved files\ndef save_files_on_server(files)\n  # get a temporary folder and create it\n  temp_folder = File.join(Dir.tmpdir, 'user')\n  FileUtils.mkdir_p(temp_folder) unless Dir.exist?(temp_folder)\n\n  # download all ActiveStorage into\n  files.map do |picture|\n    filename = picture.filename.to_s\n    filepath = File.join temp_folder, filename\n    File.open(filepath, 'wb') { |f| f.write(picture.download) }\n    filepath\n  end\nend\n~~~\n\nMaintenant que les fichiers sont sur le disque dur, nous pouvons créer le zip:\n\n\n~~~ruby\n# Create a temporary zip file \u0026 return the content as bytes\n#\n# @param filepaths [Array\u003cString\u003e] files paths\n# @return [String] as content of zip\ndef create_temporary_zip_file(filepaths)\n  require 'zip'\n  temp_file = Tempfile.new('user.zip')\n\n  begin\n    # Initialize the temp file as a zip file\n    Zip::OutputStream.open(temp_file) { |zos| }\n\n    # open the zip\n    Zip::File.open(temp_file.path, Zip::File::CREATE) do |zip|\n      filepaths.each do |filepath|\n        filename = File.basename filepath\n        # add file into the zip\n        zip.add filename, filepath\n      end\n    end\n\n    return File.read(temp_file.path)\n  ensure\n    # close all ressources \u0026 remove temporary files\n    temp_file.close\n    temp_file.unlink\n    filepaths.each { |filepath| FileUtils.rm(filepath) }\n  end\nend\n~~~\n\nIl suffit juste ensuite d'envoyer le contenu du fichier avec la méthode [`send_data`][send_data] et d'envoyer le contenu du zip. Nous utilisons la méthode [`respond_to`][respond_to] pour envoyer l'archive lorsque le format demandé est un zip.\n\n~~~ruby\n# app/controllers/users_controller.rb\nclass UsersController \u003c ApplicationController\n\n  # ...\n\n  # GET /users/1\n  # GET /users/1.json\n  def show\n    respond_to do |format|\n      format.html { render }\n      format.zip do\n        files = save_files_on_server @user.pictures\n        zip_data = create_temporary_zip_file files\n\n        send_data(zip_data, type: 'application/zip', filename: 'user.zip')\n      end\n    end\n  end\n\nend\n~~~\n\n\u003e Vous pouvez voir le [fichier complet ici](https://github.com/madeindjs/zip_example/blob/a0fab8ec8d85bf839948c84a11badaa61b766268/app/controllers/users_controller.rb).\n\nLes tests passent désormais\n\n~~~\n$ rake test\nRun options: --seed 43367\n\n# Running:\n\n........\n\nFinished in 0.220150s, 36.3389 runs/s, 49.9660 assertions/s.\n8 runs, 11 assertions, 0 failures, 0 errors, 0 skips\n~~~\n\n### Factorisation\n\nNous allons peut-être être amenés à utiliser ce code pour d'autres modèles. Afin de **factoriser** cela, Rails nous offre un excellent outil: les [`ActiveSupport::Concern`][concerns_api]!\n\nPour cela, il suffit de créer un module dans le dossier *app/controllers/concerns* et de le faire hériter de [`ActiveSupport::Concern`][concerns_api]. Ensuite, je **déplace** toutes les méthodes que nous avons créées jusqu'ici. Et, pour utiliser notre *concern*, je crée une méthode `send_zip` (je l'utiliserai dans le *controller*).\n\n~~~ruby\n# app/controllers/concerns/generate_zip.rb\n\nmodule GenerateZip\n  extend ActiveSupport::Concern\n\n  protected\n\n  # Zip all given files into a zip and send it with `send_data`\n  #\n  # @param active_storages [ActiveStorage::Attached::Many] files to save\n  # @param filename [ActiveStorage::Attached::Many] files to save\n  def send_zip(active_storages, filename: 'my.zip')\n    files = save_files_on_server active_storages\n    zip_data = create_temporary_zip_file files\n\n    send_data(zip_data, type: 'application/zip', filename: filename)\n  end\n\n  private\n\n  # Download active storage files on server in a temporary folder\n  #\n  # @param files [ActiveStorage::Attached::Many] files to save\n  # @return [Array\u003cString\u003e] files paths of saved files\n  def save_files_on_server(files)\n    # get a temporary folder and create it\n    temp_folder = File.join(Dir.tmpdir, 'user')\n    FileUtils.mkdir_p(temp_folder) unless Dir.exist?(temp_folder)\n\n    # download all ActiveStorage into\n    files.map do |picture|\n      filename = picture.filename.to_s\n      filepath = File.join temp_folder, filename\n      File.open(filepath, 'wb') { |f| f.write(picture.download) }\n      filepath\n    end\n  end\n\n  # Create a temporary zip file \u0026 return the content as bytes\n  #\n  # @param filepaths [Array\u003cString\u003e] files paths\n  # @return [String] as content of zip\n  def create_temporary_zip_file(filepaths)\n    require 'zip'\n    temp_file = Tempfile.new('user.zip')\n\n    begin\n      # Initialize the temp file as a zip file\n      Zip::OutputStream.open(temp_file) { |zos| }\n\n      # open the zip\n      Zip::File.open(temp_file.path, Zip::File::CREATE) do |zip|\n        filepaths.each do |filepath|\n          filename = File.basename filepath\n          # add file into the zip\n          zip.add filename, filepath\n        end\n      end\n\n      return File.read(temp_file.path)\n    ensure\n      # close all ressources \u0026 remove temporary files\n      temp_file.close\n      temp_file.unlink\n      filepaths.each { |filepath| FileUtils.rm(filepath) }\n    end\n  end\nend\n~~~\n\nDans le *controleur*, j'`include` simplement notre *concern* et j'utilise simplement la méthode `send_zip`\n\n~~~ruby\n# app/controllers/users_controller.rb\nclass UsersController \u003c ApplicationController\n  include GenerateZip\n\n  # ...\n\n  # GET /users/1\n  # GET /users/1.json\n  def show\n    respond_to do |format|\n      format.html { render }\n      format.zip { send_zip @user.pictures }\n    end\n  end\n\nend\n~~~\n\nEt voilà. C'est quand même plus sympa, non? Vous pouvez trouver le code [ici](https://github.com/madeindjs/zip_example/commit/67a8bcb8fd6124fdb7a2c8c3f2fd85fcbd704e5b).\n\n## Création d'une librairie\n\nC'est très bien mais je vous sens un peu déçu... En effet, si nous voulons utiliser ce module sur un autre projet, nous serions tentés de **copier/coller** le module de projets en projets.. et **c'est mal**.\n\nNe faites pas ça, nous pouvons aller plus loin! Nous pouvons **déplacer** notre code dans une **librairie** qui nous permettra de **réutiliser** notre *concern* dans une infinité d'autres projets!\n\n### Création de la gem\n\nPour cela rien de plus facile. Quittons deux secondes notre projet et **créons** une gem avec [bundler][bundler]:\n\n~~~bash\n$ bundle gem activestorage-zip\n$ cd activestorage-zip\n~~~\n\nNous devons spécifier les **dépendances** de notre librairie. Évidemment, nous avons besoin de Rails 5.2 et de [rubyzip][rubyzip]:\n\n~~~bash\n$ bundle add rails\n$ bundle add rubyzip\n~~~\n\nEt ensuite, je déplace tout le concern dans le fichier\n\n~~~ruby\n# lib/active_storage/send_zip.rb\nrequire 'active_storage/send_zip/version'\nrequire 'rails'\nrequire 'zip'\n\nmodule ActiveStorage\n  module SendZip\n    extend ActiveSupport::Concern\n\n    protected\n\n    # ...\n\n  end\nend\n~~~\n\n\u003e Vous pouvez consulter le [fichier complet ici](https://github.com/madeindjs/active_storage-send_zip/blob/master/lib/active_storage/send_zip.rb)\n\nEt voilà! C'est tout! C'était vraiment simple!\n\n### Utilisation de la gem\n\nMaintenant nous allons essayer d'**utiliser** notre gem sur notre projet précédent (avant de la publier sur [Rubygem](https://guides.rubygems.org/) par exemple). J' installe donc la gem en local avec cette commande:\n\n~~~ruby\n$ rake install:local\n~~~\n\nEt maintenant revenons au projet  *example_zip*. Il suffit de d'ajouter notre gem au *Gemfile*:\n\n~~~ruby\n# Gemfile\ngem 'active_storage-send_zip', '~\u003e 0.1.0'\n~~~\n\n\u003e n'oubliez pas le `bundle install` qui va bien\n\net de l'utiliser dans notre *controller*:\n\n~~~ruby\n# app/controllers/users_controller.rb\nclass UsersController \u003c ApplicationController\n  include ActiveStorage::SendZip\n\n  # ...\n\n  # GET /users/1\n  # GET /users/1.zip\n  def show\n    respond_to do |format|\n      format.html { render }\n      format.zip { send_zip @user.pictures }\n    end\n  end\n~~~\n\nEt pour vérifier que tout fonctionne, on relance nos tests:\n\n~~~\n$ rake test\nRun options: --seed 4817\n\n# Running:\n\n........\n\nFinished in 0.250440s, 31.9437 runs/s, 43.9226 assertions/s.\n8 runs, 11 assertions, 0 failures, 0 errors, 0 skips\n~~~\n\nMagnifique!\n\nNous pouvons maintenant [publier notre gem sur rubygems.org](https://guides.rubygems.org/publishing/)\n\n## Conclusion\n\nNous avons donc vu que, passé la complexité de la création du zip, l'utilisation du *concern* devient très simple. De plus, en créant ma propre gem (ce qui est vraiment facile), j'ai pu éviter de la **duplication** de code entre plusieurs projets. J'ai aussi contribué à la communauté Rails (à mon faible niveau :) ).\n\nMais j'ai effleuré le sujet. Il aurait aussi été sympa de tester unitairement notre gem afin d'avoir une meilleure couverture. Nous aurions aussi pu proposer une méthode pour créer le zip directement en mémoire vive.\n\nMais ne vous inquiétez pas, le code est disponible sur  Github:\n\n- l'application Rails: \u003chttps://github.com/madeindjs/zip_example\u003e\n- la gem: \u003chttps://github.com/madeindjs/active_storage-send_zip\u003e\n\nN’hésitez pas à *forker* ou me donner un retour sur d'éventuelles améliorations possibles.\n\n\n## Liens\n\n- \u003chttps://www.grafikart.fr/tutoriels/active-storage-1008\u003e\n- \u003chttps://stackoverflow.com/questions/50529659/download-an-active-storage-attachment-to-disc\u003e\n- \u003chttps://thinkingeek.com/2013/11/15/create-temporary-zip-file-send-response-rails/\u003e\n- \u003chttps://www.sitepoint.com/accept-and-send-zip-archives-with-rails-and-rubyzip/\u003e\n- \u003chttps://www.synbioz.com/blog/Rails_4_utilisation_des_concerns\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmadeindjs%2Fzip_example","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmadeindjs%2Fzip_example","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmadeindjs%2Fzip_example/lists"}