Skip to content

Instantly share code, notes, and snippets.

@rodrigotoledo
Last active December 18, 2024 18:48
Show Gist options
  • Save rodrigotoledo/3cf7bc4cf8950ec482b96596c6a13cba to your computer and use it in GitHub Desktop.
Save rodrigotoledo/3cf7bc4cf8950ec482b96596c6a13cba to your computer and use it in GitHub Desktop.
readme_receita_mais.md

Atualização de Aplicações com Ruby on Rails - Saindo do Legado

Neste curso, vamos baixar um projeto legado com rails 5.2.x e Ruby 2.7.x e atualizá-lo para uma versão mais atual como 7.x. Ao longo deste curso, abordaremos desde a instalação das tecnologias necessárias até testes desenvolvidos garantindo a qualidade do projeto.

Tecnologias Utilizadas

  • Ruby on Rails: Framework web MVC para desenvolvimento rápido de aplicações web em Ruby.
  • Banco de Dados PostgreSQL: Banco de dados completo e integração com o Rails para armazenamento dos dados da aplicação.
  • Bootstrap: Framework de estilos CSS
  • JQuery: Framework de javascript jQuery

Diferenciais do Curso

  • Instalação e utilização do RVM para controle de versões do Ruby e gerenciamento de ambientes.
  • Cobertura completa de testes utilizando MiniTest para garantir a qualidade do código.

Conteúdo do Curso

Com o RVM instalado, devamos baixar e inicializar o projeto Rails:

1. Clone este repositório

git clone https://github.com/seu-usuario/receitas-app.git

2. Configurando variáveis e bundler na versão correta para rodar com rails 5

Variáveis de ambiente com .env:

Configurando as variáveis de ambiente. Precisamos criar o arquivo .env baseado no arquivo .env.example com o conteúdo:

APPLICATION_NAME=receita_mais
DATABASE_HOST=localhost
DATABASE_USERNAME=postgres
DATABASE_PASSWORD=postgres
RAILS_MAX_THREADS=5
PRODUCTION_DATABASE_URL=

No arquivo .env a constante PRODUCTION_DATABASE_URL está em branco, preencha com o valor correto, por exemplo se usar heroku ou aws existe a URL do banco de dados. Este arquivo deve ser inserido ao final do arquivo .gitignore

Instale as dependências do Projeto e Iniciando:

gem install bundler -v 2.4.22 --no-doc
bundle install
rails db:drop db:create db:migrate db:seed
rails s

3. Configurando Bibliotecas para Desenvolvimento

Configuraremos as bibliotecas necessárias para o desenvolvimento da aplicação, como simplecov e outras. Abaixo, esta o conteudo que sera acrescentado ao arquivo Gemfile

# Gemfile

group :development, :test do
  # Ferramenta para debugar
  gem 'byebug', platform: :mri
  # Carrega variáveis de ambiente a partir de um arquivo .env
  gem 'dotenv-rails'
  # Analisa a cobertura de código dos testes
  gem 'simplecov'
  # Ferramenta de análise estática de código Ruby
  gem 'rubocop', require: false
  # Extensão do RuboCop para Rails
  gem 'rubocop-rails', require: false
end

group :development do
  # Anota os modelos com informações do schema do banco de dados
  gem 'annotate'
  # Ferramenta de segurança para Rails
  gem 'brakeman'
  # Ajuda a detectar queries N+1 em ActiveRecord
  gem 'bullet'
  # formata automaticamente código Ruby, mantendo consistência no estilo
  gem 'rufo'

  # Access an IRB console on exception pages or by using <%= console %> anywhere in the code.
  gem 'listen', '~> 3.0.5'
  gem 'web-console'
  # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring
  gem 'spring'
  gem 'spring-watcher-listen', '~> 2.0.0'
end

Agora instale com:

rm Gemfile.lock
cd .
bundle install

Análise de código com qualidade usando rubocop:

E acrescentamos o arquivo .rubocop.yml na raiz do projeto, mas atenção, configure com o caminho que o rvm estiver em sua máquina:

require: rubocop-rails
Metrics/BlockLength:
  Max: 200
  Enabled: false
  Exclude:
    - 'Rakefile'
    - '**/*.rake'
    - 'test/**/*.rb'

4. Atualizando a versão mais atual do Ruby e Rails

O Rails para o projeto legado está na versão 5.2.x e ruby 2.7.x. A intenção é atualizar para Rails 7.1.x e ruby 3.2.1. Outros pontos:

  • Atualizar bibliotecas legadas
  • Atualizar código legado
  • Melhorar a segurança
  • Implementar testes

Então primeiramente atualize os arquivos:

.ruby-version:

ruby-3.2.1

.ruby-genset:

receita_mais

.rvmrc:

rvm use 3.2.1@receita_mais --create

Então vamos atualizar a versão do Rails atualizendo o arquivo Gemfile para a versão mais atual, e colocar o que é necessário, basicamente o início do arquivo ficará assim:

# Gemfile
gem 'rails', '~> 7.1.3'

gem 'pg'

gem 'puma', '>= 5.0'

gem 'jquery-rails'

gem 'sprockets-rails'

gem 'importmap-rails'

gem 'turbo-rails'

gem 'stimulus-rails'

Note que algumas gems foram removidas:

  • coffee-rails
  • sass-rails
  • uglifier
  • turbolinks
  • jbuilder

Em seguida os comandos (quando perguntar se deseja prosseguir, informe que sim):

cd .
rm Gemfile.lock
gem install bundler
bundle install
rails app:update
rails db:migrate

Nossa! Muitos arquivos pedindo para serem atualizados, é por isso que usamos o git, pra entender o que aconteceu de novidade.

Para maiores informações do que vai ocorrer veja em https://edgeguides.rubyonrails.org/upgrading_ruby_on_rails.html

5. Instalando o que é necessário para Rails 7

Começando com importmap-rails que permite importar módulos JavaScript usando nomes lógicos que mapeiam para versões. Rode o comando:

rails importmap:install

Agora turbo-rails que oferece a velocidade de um aplicativo da web de página única sem a necessidade de escrever nenhum JavaScript, também oferencendo usando partials que são entregues de forma assíncrona por meio de uma conexão de web socket. Instale com os comandos:

rails turbo:install
rails turbo:install:redis

Em modo desenvolvimento o adaptador Async não permite Turbo Stream broadcasting por isto a gem redis foi instalada automaticamente. O serviço então deve existir em sua máquina, sugiro usar docker para isto. Na pasta lib/dockers existem alguns exemplos de composes e um deles é redis, facilmente subiria o serviço em sua máquina.

Agora com stimulus-rails é uma estrutura JavaScript com ambições modestas. Instalando com:

rails stimulus:install

Migrar da gem paperclip que não tem mais suporte para o nativo que é active_storage. Primeiro geramos a migration e adicionamos a gem que ajuda no processamento de variação de tamanhos das imagens:

rails active_storage:install
rails db:migrate
bundle add image_processing
bundle install

Note que já existiam algumas migrations que tentam resolver isto, mas sem gem active_storage.

A idéia então é:

  • Sair da gem paperclip e utilizar o active_storage
  • Eliminar o model RecipeImage e a associação dentro de Recipe ligando a ele
  • Colocar validações próprias dentro do model Recipe sobre as imagens geradas
  • No model Recipe utilizar active_store

Sair da gem paperclip e ir para a gem active_storage

Comece colocando no model Recipe:

  has_many_attached :images

  validate :validates_images_types

  private

  def validates_images_types
    return if images.blank?

    images.each do |image|
      errors.add(:images, 'must be an image file') unless image.content_type.starts_with?('image/')
    end
  end

Agora crie a rake para sair dos dados que estão no paperclip para active_storage. Crie o arquivo lib/tasks/images.rake com o conteúdo:

# frozen_string_literal: true

namespace :images do
  desc 'Migrate images from RecipeImage to Recipe'
  task migrate_recipe_images: :environment do
    RecipeImage.find_each(batch_size: 100) do |recipe_image|
      recipe = recipe_image.recipe
      recipe.images.attach(io: File.open(recipe_image.image.path), filename: recipe_image.image_file_name)
    end
  end
end

Rodamos a migração dos dados com:

bundle exec rake images:migrate_recipe_images

Após rodar migração de imagens para active_storage é necessário remover como elas são chamadas nos models:

app/models/recipe.rb

Remover a linha:

  has_many :recipe_images, dependent: :destroy

Também é necessário modificar nas views como os arquivos de imagens são chamados. Com a gem image_processing que foi instalada, a forma que se processa o tamanho de imagem é em própria execução e utiliza cache.

Basicamente as views que sofrerão alterações são:

  • app/views/home/_recipes.html.erb
  • app/views/recipes/_form.html.erb
  • app/views/recipes/show.html.erb

Todas basicamente mudando de recipe_images para images, atenção que deve ser acrescentada a variação de tamanho desejada (.variant...), um exemplo abaixo no arquivo:

  • app/views/home/_recipes.html.erb
<% unless recipe.images.attached? %>
  Sem imagens
<% else %>
  <%= image_tag(recipe.images.first.variant(resize: "400x400").processed, class: "img-fluid", alt: "Imagem da Receita") %>
<% end %>
  • app/views/recipes/_form.html.erb
<% unless recipe.images.blank? %>
  <h4 class="mt-4 mb-4">Imagens da Receita</h4>
  <div class="row">
    <% recipe.images.each_slice(4) do |image_group| %>
      <% image_group.each do |recipe_image| %>
        <div class="col-md-3 mb-3">
          <div class="card">
          <%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
          <div class="card-body">
            <%= button_to 'Excluir', delete_image_recipe_path(id: recipe.id, record_id: image.record_id), method: :delete, class: 'btn btn-sm btn-danger btn-block', onclick: "return confirm('Tem certeza que deseja prosseguir?')" %>
          </div>
        </div>
      </div>
    <% end %>
    <% (4 - image_group.size).times do %>
      <div class="col-md-3"></div>
    <% end %>
  <% end %>
  </div>
<% end %>
  • app/views/recipes/show.html.erb
  <% unless @recipe.images.blank? %>
    <h4 class="mt-4 mb-4">Imagens da Receita</h4>
    <div class="row">
      <% @recipe.images.each_slice(4) do |image_group| %>
        <% image_group.each do |image| %>
          <div class="col-md-3 mb-3">
            <div class="card">
              <%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
            </div>
          </div>
        <% end %>
        <% (4 - image_group.size).times do %>
          <div class="col-md-3"></div>
        <% end %>
      <% end %>
    </div>
  <% end %>

Removendo turbo_links:

Como a gem foi removida, é necessário remover em alguns lugares:

  • app/assets/javascripts/application.js
  • app/views/layouts/application.html.erb

Em app/assets/javascripts/application.js remova a linha:

//= require turbolinks

Em app/views/layouts/application.html.erb modifique os conteúdos:

<%= stylesheet_link_tag 'application', media: 'all' %>
<%= javascript_include_tag 'application' %>

Removendo arquivos que não utilizaremos:

É preciso remover arquivos javascript, controllers, model, scss e coffee_script, então rode:

rm app/controllers/recipe_images_controller.rb
rm app/models/recipe_image.rb
rm app/assets/javascripts/cable.js
rm app/assets/stylesheets/*.scss
rm app/assets/javascripts/*.coffee

Corrigindo arquivos javascript:

No arquivo app/assets/config/manifest.js o conteúdo deve ser:

//= link_tree ../images
//= link_directory ../stylesheets .css
//= link_tree ../../javascript .js
//= link_tree ../../../vendor/javascript .js

E no arquivo app/assets/javascripts/application.js ficará somente com:

//= require jquery
//= require jquery_ujs
//= require_tree .

Corrigindo controllers:

Primeiramente no controller de app/controllers/recipes_controller.rb é necessário:

  • modificar os métodos create e update com o código necessário
  • criar o método que irá remover images
  • remover e adicionar rotas corretas
  • modificar para aceitar o parámetro images[]
  • corrigir views para salvar e excluir images
  def create
    @recipe = current_user.recipes.build(recipe_params)
    if @recipe.save
      redirect_to recipe_url(@recipe), notice: 'Receita criada com sucesso.'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def update
    redirect_to root_path if @recipe.user_id != current_user.id && return
    if @recipe.update!(recipe_params)
      redirect_to recipe_url(@recipe), notice: 'Receita atualizada com sucesso.'
    else
      render :edit, status: :unprocessable_entity
    end
  end

no começo do mesmo arquivo modifique a linha para e acrescente o método para remover a imagem:

  before_action :set_recipe, only: %i[delete_image show edit update destroy]

  def delete_image
    image = @recipe.images.find_by(id: params[:record_id])
    image.purge if image
    redirect_to recipe_url(@recipe), notice: 'Imagem excluída com sucesso.'
  end

e ao final do mesmo:

  def recipe_params
    params.require(:recipe).permit(:title, :ingredients, :instructions, images: [])
  end

Alterando o arquivo config/routes.rb deve ficar apenas com:

Rails.application.routes.draw do
  devise_for :users
  resources :recipes do
    collection do
      get 'by_user/:user_id', to: 'recipes#by_user', as: 'by_user'
    end
    member do
      delete 'delete_image/:record_id', to: 'recipes#delete_image', as: :delete_image
    end
  end
  root 'home#index'
end

Alterando o arquivo app/views/recipes/_form.html.erb no início com:

<%= form_with(model: recipe, local: true) do |f| %>

No atributo images do form altere para:

<%= f.file_field :images, multiple: true, class: 'form-control' %>

E ao final deve ser alterado para imagens processadas:

<% unless recipe.images.blank? %>
  <h4 class="mt-4 mb-4">Imagens da Receita</h4>
  <div class="row">
    <% recipe.images.each_slice(4) do |image_group| %>
      <% image_group.each do |recipe_image| %>
        <div class="col-md-3 mb-3">
          <div class="card">
          <%= image_tag image.variant(resize: "150x150").processed, class: "card-img-top img-thumbnail", style: "max-height: 150px; object-fit: cover;" %>
          <div class="card-body">
            <%= button_to 'Excluir', delete_image_recipe_path(id: recipe.id, record_id: image.record_id), method: :delete, class: 'btn btn-sm btn-danger btn-block', onclick: "return confirm('Tem certeza que deseja prosseguir?')" %>
          </div>
        </div>
      </div>
    <% end %>
    <% (4 - image_group.size).times do %>
      <div class="col-md-3"></div>
    <% end %>
  <% end %>
  </div>
<% end %>

6. Iniciando o ambiente de Desenvolvimento com Testes

Configuraremos o ambiente de desenvolvimento para rodar testes automatizados utilizando o Minitest que é o padrão do adotado pelo Rails. Também será realizada a cobertura de testes para garantir a qualidade do código.

Entao agora vamos configurar os testes, pare o servidor rails e rode os comandos:

Adicionar o simplecov no início do arquivo test/test_helper.rb com:

require 'simplecov'
SimpleCov.start do
  add_filter 'test'
end
require 'minitest/autorun'

Basta rodar o comando:

rake test

E os testes serao rodados e a pasta coverage sera criada na raiz do projeto. Inclusive ja adicione a mesma no arquivo .gitignore

Cada alteracao feita em qualquer parte do projeto ira rodar os testes.

A cobertura por testes tera aumentado se rodar o test novamente, basta abrir o arquivo coverage/index.html para ver o resultado.

Agora realmente eh hora de entender o que eh util em testes e como codificar com testes.

7. Desenvolvimento dos Testes

Como o projeto foi criado sem testes, precisamos criar arquivos manualmente para cada tipo de teste.

Entendendo fixtures:

Em Minitest, as fixtures são conjuntos predefinidos de dados usados para configurar o ambiente de teste antes da execução dos testes. Elas são armazenadas em arquivos YAML na pasta test/fixtures e representam um estado conhecido do sistema em um determinado ponto no tempo.

Precisamos então as fixtures para cada model do sistema.

As fixtures de users e recipes já estão criadas em test/fixtures/....

Adicione onde serão armazenados os arquivos de teste editando o arquivo config/storage.yml:

test_fixtures:
  service: Disk
  root: <%= Rails.root.join("tmp/storage_fixtures") %>

E agora as fixtures para cada arquivo, primeiro em test/fixtures/active_storage/attachments.yml

# test/fixtures/active_storage/attachments.yml
recipe_image0:
  name: image
  record: recipe_testing (Recipe)
  blob: recipe_image_blob0

recipe_image1:
  name: image
  record: recipe_testing (Recipe)
  blob: recipe_image_blob1

recipe_image2:
  name: image
  record: recipe_testing (Recipe)
  blob: recipe_image_blob2

recipe_image3:
  name: image
  record: recipe_testing (Recipe)
  blob: recipe_image_blob3

recipe_image4:
  name: image
  record: recipe_testing (Recipe)
  blob: recipe_image_blob4

Veja que na posição record indica onde se pertence esse attachment e para na frente automaticamente (Recipe) qe indica qual model.

E para realmente pegar os arquivos locais que serão usados nos testes no arquivo test/fixtures/active_storage/blobs.yml:

# test/fixtures/active_storage/blobs.yml
recipe_image_blob0: <%= ActiveStorage::FixtureSet.blob filename: "0.png", service_name: "test_fixtures" %>

recipe_image_blob1: <%= ActiveStorage::FixtureSet.blob filename: "1.png", service_name: "test_fixtures" %>

recipe_image_blob2: <%= ActiveStorage::FixtureSet.blob filename: "2.png", service_name: "test_fixtures" %>

recipe_image_blob3: <%= ActiveStorage::FixtureSet.blob filename: "3.png", service_name: "test_fixtures" %>

recipe_image_blob4: <%= ActiveStorage::FixtureSet.blob filename: "4.png", service_name: "test_fixtures" %>

Algo muito importante é limpar os arquivos de teste que são executados cada vez que os testes são rodados, então no arquivo test_helper.rb adicione ao final:

at_exit do
  FileUtils.rm_rf(Dir["#{Rails.root.join('tmp/storage_fixtures/*')}"])
end

Testes de models:

Para cada model que corresponde a uma tabela no banco teremos seus testes para garantir que eles operam corretamente.

Iniciando com users

test/models/user_test.rb

require 'test_helper'

class UserTest < ActiveSupport::TestCase
  test 'should save user with valid attributes' do
    user = User.new(
      email: '[email protected]',
      password: 'password',
      password_confirmation: 'password'
    )
    assert user.save, 'User was not saved with valid attributes'
  end

  test 'should not save user without email' do
    user = User.new(
      password: 'password',
      password_confirmation: 'password'
    )
    assert_not user.save, 'User was saved without email'
  end

  test 'should not save user with invalid email format' do
    user = User.new(
      email: 'invalid-email',
      password: 'password',
      password_confirmation: 'password'
    )
    assert_not user.save, 'User was saved with invalid email format'
  end

  test 'should not save user with password confirmation mismatch' do
    user = User.new(
      email: '[email protected]',
      password: 'password',
      password_confirmation: 'mismatch'
    )
    assert_not user.save, 'User was saved with password confirmation mismatch'
  end
end

Agora com recipes

test/models/recipe_test.rb

require 'test_helper'

class RecipeTest < ActiveSupport::TestCase
  test 'should save recipe with valid attributes' do
    user = users(:user_testing)
    recipe = user.recipes.build(
      title: 'Test Recipe',
      ingredients: 'Ingredient 1, Ingredient 2',
      instructions: 'Step 1, Step 2'
    )

    recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/0.png')), filename: '0.png',
                         content_type: 'image/png')

    assert recipe.save, 'Recipe was not saved with valid attributes'
  end

  test 'should not save recipe without images' do
    user = users(:user_testing)
    recipe = user.recipes.build(
      title: 'Test Recipe',
      ingredients: 'Ingredient 1, Ingredient 2',
      instructions: 'Step 1, Step 2'
    )

    assert_not recipe.save, 'Recipe was saved without images'
    assert_not_empty recipe.errors[:images], 'Recipe must have at least one image attached'
  end

  test 'should not save recipe with invalid image format' do
    user = users(:user_testing)
    recipe = user.recipes.build(
      title: 'Test Recipe',
      ingredients: 'Ingredient 1, Ingredient 2',
      instructions: 'Step 1, Step 2'
    )

    recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/invalid_image.txt')),
                         filename: 'invalid_image.txt', content_type: 'text/plain')

    assert_not recipe.save, 'Recipe was saved with invalid image format'
    assert_not_empty recipe.errors[:images], 'Recipe must have an image file attached'
  end
end

Testes de controllers:

Os testes irâo cobrir todo o cenário de operações.

Testando HomeController test/controllers/home_controller_test.rb:

# test/controllers/home_controller_test.rb
require 'test_helper'

class HomeControllerTest < ActionController::TestCase
  test "should get index" do
    get :index
    assert_response :success
    assert_not_nil assigns(:recipes)
  end
end

Perceba que se testa não está nula pois as fixtures são carregadas iniciamente automaticamente

Testando RecipesController test/controller/recipes_controller_test.rb

test/controller/recipes_controller_test.rb

require 'test_helper'

class RecipesControllerTest < ActionController::TestCase
  setup do
    @user = users(:user_testing)
    @recipe = recipes(:recipe_testing)
    sign_in @user
  end

  test 'should get index' do
    get :index
    assert_response :success
    assert_not_nil assigns(:recipes)
  end

  test 'should get new' do
    get :new
    assert_response :success
  end

  test 'should create recipe' do
    assert_difference('Recipe.count') do
      post :create,
           params: { recipe: { title: @recipe.title, ingredients: @recipe.ingredients,
                               instructions: @recipe.instructions } }
    end

    assert_redirected_to recipe_url(assigns(:recipe))
  end

  test 'should render new template with unprocessable entity status if recipe is not saved' do
    post :create, params: { recipe: { title: '', description: '' } }

    assert_response :unprocessable_entity
    assert_template :new
  end

  test 'should show recipe' do
    get :show, params: { id: @recipe }
    assert_response :success
  end

  test 'should get edit' do
    get :edit, params: { id: @recipe }
    assert_response :success
  end

  test 'should update recipe' do
    patch :update,
          params: { id: @recipe,
                    recipe: { title: @recipe.title, ingredients: @recipe.ingredients,
                              instructions: @recipe.instructions } }
    assert_redirected_to recipe_url(assigns(:recipe))
  end

  test 'should render edit template with unprocessable entity status if recipe update fails' do
    params = { title: '', description: 'Updated description' }

    put :update, params: { id: @recipe.id, recipe: params }

    assert_response :unprocessable_entity
    assert_template :edit
  end

  test 'should destroy recipe' do
    assert_difference('Recipe.count', -1) do
      delete :destroy, params: { id: @recipe }
    end

    assert_redirected_to recipes_url
  end

  test 'should delete image' do
    @recipe.images.attach(io: File.open(Rails.root.join('test/fixtures/files/0.png')), filename: '0.png',
                          content_type: 'image/png')
    delete :delete_image, params: { id: @recipe.id, record_id: @recipe.images.last.id }
    assert_redirected_to recipe_url(assigns(:recipe))
    assert_equal 'Imagem excluída com sucesso.', flash[:notice]
  end

  test 'should get recipes by user' do
    get :by_user, params: { user_id: @user.id }
    assert_response :success
    assert_not_nil assigns(:recipes)
  end
end

Pronto, novamente com o comando de teste e tudo será visto como coberto e a aplicação pronta para receber acessos.

8. Segurança

Aumentaremos a segurança da aplicação ocultando parâmetros sensíveis.

Em config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn
]

Rode novamente os testes.

Extras

Aqui vão alguns comandos úteis que estão disponíveis por conta de gems:

Irá gerar um arquivo com as possíveis falhas de segurança em seu projeto:

brakeman -o coverage/output.html

Gera comentários úteis em seus arquivos:

annotate

Eu gosto muito do editor visual studio, então algumas das extensões que utilizo:

Pré-requisitos

  • Conhecimento básico de Ruby e programação web.
  • Instalação do RVM.
  • Ter o Ruby e o Rails instalados na máquina a partir do RVM.

Ao final deste curso, você estará apto a entender que uma aplicação legada em Ruby on Rails pode e deve ser atualizada para uma versão atualizada.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment