Voltar
E-commerce Admin Part II - Jbuilder, CRUD e Services
Criação de endpoints principais e inclusão de services
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