Voltar

E-commerce Admin Part II - Jbuilder, CRUD e Services

Criação de endpoints principais e inclusão de services
14 de março de 2023 às 02:46

Integrando o Jbuilder

Jbuilder é uma gem para o Ruby on Rails que permite criar views em JSON de maneira mais eficiente e expressiva. Com o Jbuilder, é possível escrever views em JSON em arquivos separados, em vez de incorporá-las diretamente nas actions dos controllers. Além disso, o Jbuilder oferece recursos avançados, como a possibilidade de incluir fragments de outras views e a capacidade de construir JSONs de forma dinâmica. Em resumo, o Jbuilder é uma ferramenta muito útil para criar views em JSON mais limpas e fáceis de manter em projetos Ruby on Rails.

Exemplos de algumas views usando jbuilder:

json.coupons do
  json.array! @coupons, :id, :name, :code, :status, :discount_value, :max_use, :due_date
end

Endpoint para criação de categoria

Para começar, vamos primeiro fazer os testes da rota de cadastro de categoria. Antes de criamos os casos de teste especificamente, é importante pensarmos no que é importante testar numa rota como essa e foi pensado dois cenários:

  • Quando os parâmetros são válidos
  • Quando os parâmetros são inválidos

Portanto, vamos criar dois contextos distintos dentro dos testes de requisição. Adicionaremos dois contextos internos, um para parâmetros válidos e outro para parâmetros inválidos. Para os parâmetros válidos, é importante testar três aspectos fundamentais: se uma nova Categoria foi adicionada com sucesso, se ela é idêntica à última categoria adicionada no banco e se o status da resposta é 200 (:ok do Rails). Assim, adicionaremos esses três testes em nosso código.

context "GET /categories" do
  ...
end

context "POST /categories" do
  let(:url) { "/admin/v1/categories" }

  context "with valid params" do
  end

  context "with valid params" do
   let(:category_params) { { category: attributes_for(:category) }.to_json }

   it 'adds a new Category' do
     expect do
      post url, headers: auth_header(user), params: category_params
     end.to change(Category, :count).by(1)
   end
   
   it 'returns last added Category' do
    post url, headers: auth_header(user), params: category_params
    expected_category = Category.last.as_json(only: %i(id name))
    expect(body_json['category']).to eq expected_category
  end

   it 'returns success status' do
    post url, headers: auth_header(user), params: category_params
    expect(response).to have_http_status(:ok)
  end
 end
end

Para que tais testes passem é preciso implementar controller de Categories que cubram todos os cenários descritos com parâmetros válidos. Criamos o método e estamos verificando se params possui a chave category antes de fazermos o permit.

def create
   @category = Category.new
   @category.attributes = category_params
   @category.save!
   render :show
 rescue
   render json: { errors: { fields: @category.errors.messages } }, status: :unprocessable_entity
 end
end

private

def category_params
  return {} unless params.has_key?(:category)
  params.require(:category).permit(:id, :name)
end

Estamos salvando o model @category com save!, que retorna um exceção caso ocorra um erro e renderizando a view show. Caso @category tenha algum erro, ele cairá no rescue e vai renderizar um JSON como erro. Com o endpoint criado, precisamos criar a view show do Jbuilder para renderizar a categoria no render :show

json.category do
  json.(@category, :id, :name)
end

Atualizando e removendo uma categoria

Seguimos a mesma lógica de criar os testes antes de construir endpoints, intuito é que tenha uma alta cobertura de todos os enpoints da aplicação. Os testes são uma parte fundamental do processo de desenvolvimento de software em qualquer aplicação, incluindo o backend. Eles garantem que o código produzido está funcionando corretamente, atendendo às especificações e cumprindo as necessidades do usuário final. Além disso, os testes ajudam a detectar e corrigir erros e falhas no código antes que ele seja implantado em produção, evitando problemas e reduzindo custos.

Código de atualização de categoria

def update
  @category = Category.find(params[:id])
  @category.attributes = category_params
  save_category!
end

Código de remoção de categoria

def destroy
  @category = Category.find(params[:id])
  @category.destroy!
rescue
  render_error(fields: @category.errors.messages)
end

Como é notado nos dois códigos é utilizando uma procura (find) por categoria passando como parametro o id de alguma categoria, tanto a action update quanto a destroy precisam carregar a categoria antes de atuar nela. Então vamos abstrair e colocar o carregamento dela em uma função específica e chamar com um before_action para estas duas rotas.

def load_category
  @category = Category.find(params[:id])
end


class CategoriesController < ApiController
  before_action :load_category, only: [:update, :destroy]
  
  ...
end

Service de busca e ordenação

Em Ruby on Rails, "services" são classes que encapsulam a lógica de negócios de um aplicativo que não se encaixam naturalmente em outros objetos, como modelos ou controladores. Os services geralmente são usados para realizar tarefas complexas ou que exigem interações com várias partes do sistema. Eles ajudam a manter o código limpo e organizado, separando a lógica de negócios do restante do aplicativo e tornando-a mais fácil de testar e modificar.

Um exemplo comum de uso de services é em um aplicativo de comércio eletrônico, onde um serviço de "checkout" pode ser criado para gerenciar o processo de pagamento, verificação de estoque, envio de notificações, entre outras tarefas relacionadas ao checkout.

Neste caso, iremos criar um service de busca e ordenação chamado de ModelLoadingService. Para este serviço, a ideia é implementar um parâmetro "call" que receberá dois parâmetros: um obrigatório, que será o modelo onde a busca será realizada, e outro opcional, que são os parâmetros da busca. Espera-se que esses parâmetros incluam "order" para a chave de ordenação, "page" e "length" para paginação e "search[:name]" para a busca por nome. Em Ruby on Rails, um service é geralmente um PORO (Pure and Old Ruby Object), que é um objeto puro do Ruby. Para implementar um service, começamos criando um método construtor, que deve receber um modelo que já implemente as funcionalidades de busca por nome (search_by_name) e paginação (paginate).

class ModelLoadingService
  def initialize(searchable_model, params = {})
    @searchable_model = searchable_model
    @params = params
    @params ||= {}
  end

  def call
    @searchable_model.search_by_name(@params.dig(:search, :name)).order(@params[:order].to_h)
        .paginate(@params[:page].to_i, @params[:length].to_i)
  end
end

Service para salvar produto

Começamos declarando a classe de erro "NotSavedProductError" que usaremos, além dos atributos do service que estarão disponíveis. O próximo passo é criar o método construtor, que deve receber os parâmetros, incluindo o produto, que é opcional.

Dentro do construtor, separamos os parâmetros recebidos na variável "params" entre aqueles que pertencem ao modelo "Product" e aqueles que pertencem à entidade associada. Também inicializamos as variáveis "errors" e "product", esta última representa o produto que está sendo criado ou atualizado.

Para garantir que nada será salvo no banco de dados em caso de erro, abrimos uma transação e usamos o "ensure" para chamar o método "save!", que implementaremos a seguir. Esse método tenta salvar tanto o "product" quanto a entidade associada "productable", e, se houver erros, eles são armazenados na variável "@errors".

Por fim, se ocorrer qualquer erro durante esse processo, lançamos novamente a exceção "NotSavedProductError".

module Admin  
  class ProductSavingService
   class NotSavedProductError < StandardError; end

   attr_reader :product, :errors

    def initialize(params, product = nil)
      params = params.deep_symbolize_keys
      @product_params = params.reject { |key| key == :productable_attributes }
      @productable_params = params[:productable_attributes] || {}
      @errors = {}
      @product = product || Product.new
    end

    def call
      Product.transaction do
      @product.attributes = @product_params.reject { |key| key == :productable }
      build_productable
      ensure
    save!
      end
    end

    def build_productable
      @product.productable ||= @product_params[:productable].camelcase.safe_constantize.new
      @product.productable.attributes = @productable_params
    end

    def save!
      save_record!(@product.productable) if @product.productable.present?
      save_record!(@product)
     raise NotSavedProductError if @errors.present?
      rescue => e
     raise NotSavedProductError
    end
    
   def save_record!(record)
     record.save!
     rescue ActiveRecord::RecordInvalid
     @errors.merge!(record.errors.messages)
   end

  end
end