Пример создания простого бэкенда на Rails API

В этой статье рассмотрим простой пример создания бэкенда на Rails API.

Мне нужно создать приложение с бэкендом на Ruby on Rails 5, чтобы на стороне фронта я мог использовать любой JS фреймворк, типа Angular2. Это означает, что визуальным представлением рельсы заниматься не должны. Все общение между приложением и фронтом должно происходить в формате JSON.

Постановка задачи.

Для начала пусть мое приложение содержит две таблицы:

  • Категории (Categories)
  • Продукты (Products)

У категорий будет только поле "Наименование" (name) типа строка. Оно должно быть уникальным в таблице.

Список категорий плоский без уровней вложенности.

Связь таблиц один ко многим: у одной категории может быть много продуктов, но у продукта может быть только одна категория.

Таблица продуктов содержит два поля: Наименование (name) и цена (price). Цена всегда должна быть больше 0 и может быть указана с копейками, т.е. с плавающей точкой.

Требования к API

Приложение должно предоставлять api по всем основным операциям CRUD:

  1. Создание новой категории (Create).
    запрос с фронта - POST /categories с телом  {  category: { name: “Candy” } }
    должен возвращать:
    • статус 201, если все прошло успешно и
    • ответ json: { id: 3, name: “Candy”, products_count: 0 }
    • статус 422, если была ошибка валидации и
    • ответ json: { errors: { name: [“can’t be blank”] } }
  2. Получение списка категорий (Read).
    запрос с фронта - GET /categories
    должен возвращать :
    • статус 200 и
    • ответ json: [ { id: 1, name: “Food”, products_count: 9 }, { id: 2, name: “Drink”, products_count: 4 }],
      где products_count - это количество продуктов в этой категории. Это значение должно меняться после добавления/удаления продуктов из таблицы базы данных.
  3. Изменение существующей категории (Update).
    запрос с фронта - PUT /categories/3  с телом  {  category: { name: “Furniture” } }
    должен возвращать:
    • статус 200, если все прошло успешно и
    • ответ json: представление записи в базе данных. тут не важно получение количества продуктов.
    • статус 422, если обновление прошло с ошибкой валидации и
    • ответ json: { errors: { name: [“can’t be blank”] } }
  4. Удаление категории (Delete).
    запрос с фронта - DELETE /categories/3
    должен возвращать:
    • статус 200 и
    • ответ json: например, представление удаленной категории, это не принципиально.
  5. Создание нового продукта в категории (Create).
    запрос с фронта - POST /categories/3/products  с телом  { product: {   name: “Butter”, price: 2.6 } }
    должен возвращать:
    • статус 201, если все прошло успешно и
    • ответ json: { id: 3  name: “Butter”, price: 2.6 }
    • статус 422, если были ошибки валидации и
    • ответ json:  { errors: { name: [“can’t be blank”] } }
  6. Получение списка продуктов для категории (Read).
    запрос с фронта - GET /categories/2/products
    должен возвращать:
    • статус 200 и
    • ответ json: [ { id: 1, name: “Beef”, price: 42.69 }, { id: 2, name: “Cookies”, price: 69.42 } ]
  7. Изменение продукта (Update).
    запрос с фронта - PUT /categories/3/products/3  с телом  { product: {   name: “Butter”, price: 5.8 } }
    должен возвращать:
    • статус 200 если все прошло успешно и
    • ответ json: представление записи из базы данных
    • статус 422, если были ошибки валидации и
    • ответ json: { errors: { name: [“can’t be blank”] } }
  8. Удаление продукта (Delete).
    запрос с фронта - DELETE /categories/3/products/3
    должен возвращать:
    • статус 200 и
    • ответ json: представление записи удаленного продукта, это не принципиально.

Создание приложения на Ruby on Rails 5.

Я предполагаю, что на компьютере уже установлен весь необходимый софт для работы с рельсой. Как минимум

  • ruby 2.4.1p111
  • RoR 5.1.5

Поэтому в терминале перехожу в каталог, где будет расположено мое приложение и набираю команду:

rails new testApp1 --api

По традиции проверим, что приложение создалось верно и доступно в браузере:

rails s

Запустится веб-сервер puma и в браузере по адресу localhost:3000 я вижу приветственную страницу Rails.

Все ОК. Идем дальше.

Создание моделей.

Модель в рельсах - это способ работы с записями таблицы базы данных. В моем примере это таблицы категорий и продуктов. Создам модель для каждой таблицы. В терминале набираю команды:

rails g model Category name:string
rails g model Product name:string price:float category:references

В каталоге app/db/migrations появились две миграции.

Для Категорий:

class CreateCategories < ActiveRecord::Migration[5.1]
  def change
    create_table :categories do |t|
      t.string :name
      t.timestamps
    end
  end
end

Для продуктов:

class CreateProducts < ActiveRecord::Migration[5.1]
  def change
    create_table :products do |t|
      t.string :name
      t.float :price
      t.references :category, index: true, foreign_key: true
      t.timestamps
    end
  end
end

Теперь можно применить миграции, чтобы таблицы были созданы в базе данных.

В терминале набираю команду:

rake db:migrate

Кроме этого в каталоге app/models были автоматом созданы файлы для описания наших моделей. Допишу в них код, устанавливающий связь 1:M между моделями. И пропишу всю необходимую валидацию. Вот что получилось в итоге:

app/models/category.rb

class Category < ApplicationRecord
    # Говорю, что категория имеет много продуктов и при удалении категории удалять и все ее продукты
    has_many :products, dependent: :destroy
    # Указываю, что перед записью нужно проверять name на наличие и уникальность
    validates :name, presence: true, uniqueness: true
end

app/models/product.rb

class Product < ApplicationRecord
    # Указываю, что продукт принадлежит категории
    belongs_to :category
    # указываю, что нужно проверять name на наличие и уникальность
    # а цену на наличие и на больше нуля
    validates :name, presence: true, uniqueness: true
    validates :price, presence: true, numericality: {greater_than: 0}
end

Теперь, когда модели настроены, таблицы созданы, чтобы заработали запросы с фронта, нужно добавить маршрутизацию.

В файл app/config/routes.rb и добавлю код:

Rails.application.routes.draw do
  resources :categories do
    resources :products
  end
end

Такая конструкция позволит построить пути ко вложенным ресурсам, такие как categories/id/products/product_id

И для проверки результата в терминале наберу:

rake routes
Эта команда выведет все возможные пути моего приложения.

Prefix Verb URI Pattern Controller#Action
category_products GET /categories/:category_id/products(.:format) products#index
POST /categories/:category_id/products(.:format) products#create
category_product GET /categories/:category_id/products/:id(.:format) products#show
PATCH /categories/:category_id/products/:id(.:format) products#update
PUT /categories/:category_id/products/:id(.:format) products#update
DELETE /categories/:category_id/products/:id(.:format) products#destroy
categories GET /categories(.:format) categories#index
POST /categories(.:format) categories#create
category GET /categories/:id(.:format) categories#show
PATCH /categories/:id(.:format) categories#update
PUT /categories/:id(.:format) categories#update
DELETE /categories/:id(.:format) categories#destroy

Тестовые данные и ручное тестирование.

Прежде чем создавать действия контроллера, наполню базу данных тестовыми данными: создам пару категорий и по нескольку продуктов в них.

Это можно сделать несколькими способами:

  • Через программный интерфейс контроллера - сейчас это невозможно, поскольку его еще нет
  • В консоли рельсов (rails c), используя методы моделей  new() и  create(). Это возможно, но долго и не интересно.
  • Используя файл seeds.rb и команду rake db:seed.

Воспользуюсь третьим вариантом.

app/db//seeds.rb:

c1 = Category.create({name: "Food"})
c2 = Category.create({name: "Drink"})

c1.products.create({name: "Bread", price: 1.5})
c1.products.create({name: "Butter",price: 2.6})
c1.products.create({name: "Pomodoro",price: 3.7})
c1.products.create({name: "Onion",price: 4.82})
c1.products.create({name: "Carrot",price: 5.99})
c2.products.create({name: "Milk", price: 3.28})
c2.products.create({name: "Water",price: 1.49})
c2.products.create({name: "Dushes",price: 1.00})

Теперь каждый раз при выполнении в терминале команды 

rake db:seed

База данных будет перезаполняться этим набором данных.

Для ручного тестирования методов программного интерфейса (RESTfull API) приложения я буду использовать расширение Postman для Chrome.

Кому интересно, погуглите или смотрите тут.

В нем я буду проверять работоспособность запросов из постановки задачи. Доменом буду использовать http://localhost:3000/.

Создание контроллеров.

Для создания контроллеров я не буду использовать генератор Rails. Просто так захотелось ))

Categories

В каталоге app/controllers  я создам файл categories_controller.rb  и определю в нем класс

class CategoriesController < ApplicationController
end

Далее по порядку буду добавлять методы.

  1. Создание новой категории (Read). Запрос POST /categories

Добавлю в контроллер метод Create

    # POST /categories
    def create
        category = Category.new(cat_params)
        if category.save
            category = find_category(category.id)
            render json: category, status: 201
        else
            # Что-то пошло не так
            render json: {errors: category.errors}, status: :unprocessable_entity
        end
    end

Вспомогательный метод cat_params() я сделал чтобы контролировать входящие параметры и сформировать белый список разрешенных параметров, остальные будут игнорироваться.

Другой метод find_category() готовит категорию для вывода в нужном формате - добавляет product_count и убирает штампы времени.

private

    def cat_params
        params.require(:category).permit(:name)
    end
    def find_category id
        Category.left_outer_joins(:products)
                            .select('categories.id, categories.name, COUNT(products.id) AS products_count')
                            .where(id: id)
                            .group('categories.id, categories.name')
    end

2. Получение списка категорий (Read). Запрос GET /categories

Добавляю в контроллер метод Index

    # GET /categories
    def index
        categories = Category.left_outer_joins(:products)
                            .select('categories.id, categories.name, COUNT(products.id) AS products_count')
                            .group('categories.id, categories.name')
        render json: categories, status: :ok
    end

и метод show, для чтения одной категории

    # GET /categories/:id
    def show
        category = find_category(params[:id])
        render json: category, status: :ok
    end

3. Изменение существующей категории (Update). Запрос PUT /categories/id

Добавлю в контроллер метод Update

    # PUT /categories/:id
    def update
        category = Category.find(params[:id])
        if category.update_attributes(cat_params)
            render json: category, status: :ok
        else
            # Что-то пошло не так
            render json: {errors: category.errors}, status: :unprocessable_entity
        end
    end

4. Удаление категории (Delete). Запрос DELETE /categories/id

Добавлю в контроллер метод Destroy

    # DELETE categories/:id
    def destroy
        category = Category.find(params[:id])
        category.destroy
        render json: category, status: 204
    end

Products

В каталоге app/controllers создам файл products_controller.rb и описываю пустой класс.

class ProductsController < ApplicationController
end

Далее создам методы по каждому пункту из постановки задачи.

  1. Создание нового продукта в категории (Create). Запрос POST /categories/3/products
    # POST /categories/:category_id/products
    def create
        product = find_category(params[:category_id]).products.new(prod_params)
        if product.save
            product = find_product(product.id)
            render json: product, status: 201
        else
            render json: {errors: product.errors}, status: :unprocessable_entity
        end
    end

Поскольку модель Products - это вложенный ресурс для категорий, работа с ним всегда будет вестись через связь category.products и для каждого запроса нужно будет найти категорию по первому параметру id запроса, чтобы следовать принципу DRY (don't repeat yourself) я создал приватный метод find_category ().

Методы prod_params() и find_product() созданы по аналогии с методами контроллера Categories.

Вот как они выглядят:

    private

    def prod_params
        params.require(:product).permit(:name, :price)
    end

    def find_category category_id
        Category.find(category_id)
    end
    def find_product id
        Product.select(:id, :name, :price).where(id: id)
    end

2. Получение списка продуктов для категории (Read). Запрос GET /categories/3/products

    # GET /categories/:category_id/products
    def index
        products = find_category(params[:category_id]).products.select(:id, :name, :price)
        render json: products, status: :ok
    end  

И для получения данных одного продукта в категории

    # GET /categories/:category_id/products/:id
    def show
        product = find_product(params[:id])
        render json: product, status: :ok
    end

3. Изменение продукта (Update). Запрос PUT /categories/3/products/3

    # PUT /categories/:category_id/products/:id
    def update
        product = Product.find(params[:id])
        if product.update_attributes(prod_params)
            product = find_product(params[:id])
            render json: product, status: :ok
        else
            render json: {errors: product.errors}, status: :unprocessable_entity
        end
    end

4. Удаление продукта (Delete). Запрос DELETE /categories/3/products/3

    # DELETE /categories/:category_id/products/:id
    def destroy
        product = Product.find(params[:id])
        product.destroy
        render json: product, status: 204
    end

 

На этом создание моего приложения закончено. 

Теперь нужно закрыть тестами весь его функционал. Делать это я буду в следующей статье.

Итоговый код всех используемых файлов.

class Category < ApplicationRecord
    has_many :products, dependent: :destroy
    validates :name, presence: true, uniqueness: true
end

 

class Product < ApplicationRecord
    belongs_to :category
    validates :name, presence: true, uniqueness: true
    validates :price, presence: true, numericality: {greater_than: 0}
end
class CategoriesController < ApplicationController

    # GET /categories
    def index
        categories = Category.left_outer_joins(:products)
                            .select('categories.id, categories.name, COUNT(products.id) AS products_count')
                            .group('categories.id, categories.name')
        render json: categories, status: :ok
    end

    # GET /categories/:id
    def show
        category = find_category(params[:id])
        render json: category, status: :ok
    end

    # POST /categories
    def create
        category = Category.new(cat_params)
        if category.save
            category = find_category(category.id)
            render json: category, status: 201
        else
            render json: {errors: category.errors}, status: :unprocessable_entity
        end
    end

    # DELETE categories/:id
    def destroy
        category = Category.find(params[:id])
        category.destroy
        render json: category, status: 204
    end

    # PUT /categories/:id
    def update
        category = Category.find(params[:id])
        if category.update_attributes(cat_params)
            render json: category, status: :ok
        else
            render json: {errors: category.errors}, status: :unprocessable_entity
        end
    end

    private

    def cat_params
        params.require(:category).permit(:name)
    end
    def find_category id
        Category.left_outer_joins(:products)
                            .select('categories.id, categories.name, COUNT(products.id) AS products_count')
                            .where(id: id)
                            .group('categories.id, categories.name')
    end
end
    

 

class ProductsController < ApplicationController

    # GET /categories/:category_id/products
    def index
        products = find_category(params[:category_id]).products.select(:id, :name, :price)
        render json: products, status: :ok
    end

    # GET /categories/:category_id/products/:id
    def show
        product = find_product(params[:id])
        render json: product, status: :ok
    end

    # POST /categories/:category_id/products
    def create
        product = find_category(params[:category_id]).products.new(prod_params)
        if product.save
            product = find_product(product.id)
            render json: product, status: 201
        else
            render json: {errors: product.errors}, status: :unprocessable_entity
        end
    end

    # PUT /categories/:category_id/products/:id
    def update
        product = Product.find(params[:id])
        if product.update_attributes(prod_params)
            product = find_product(params[:id])
            render json: product, status: :ok
        else
            render json: {errors: product.errors}, status: :unprocessable_entity
        end
    end

    # DELETE /categories/:category_id/products/:id
    def destroy
        product = Product.find(params[:id])
        product.destroy
        render json: product, status: 204
    end

    private

    def prod_params
        params.require(:product).permit(:name, :price)
    end

    def find_category category_id
        Category.find(category_id)
    end
    def find_product id
        Product.select(:id, :name, :price).where(id: id)
    end
end

 

Rails.application.routes.draw do
  resources :categories do
    resources :products
  end
end

Posted in Новости, Ruby on Rails and tagged .

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *