>
>
В этой статье рассмотрим простой пример создания бэкенда на Rails API.
Мне нужно создать приложение с бэкендом на Ruby on Rails 5, чтобы на стороне фронта я мог использовать любой JS фреймворк, типа Angular2. Это означает, что визуальным представлением рельсы заниматься не должны. Все общение между приложением и фронтом должно происходить в формате JSON.
Постановка задачи.
Для начала пусть мое приложение содержит две таблицы:
- Категории (Categories)
- Продукты (Products)
У категорий будет только поле "Наименование" (name) типа строка. Оно должно быть уникальным в таблице.
Список категорий плоский без уровней вложенности.
Связь таблиц один ко многим: у одной категории может быть много продуктов, но у продукта может быть только одна категория.
Таблица продуктов содержит два поля: Наименование (name) и цена (price). Цена всегда должна быть больше 0 и может быть указана с копейками, т.е. с плавающей точкой.
Требования к API
Приложение должно предоставлять api по всем основным операциям CRUD:
- Создание новой категории (Create).
запрос с фронта - POST /categories с телом { category: { name: “Candy” } }
должен возвращать:- статус 201, если все прошло успешно и
- ответ json: { id: 3, name: “Candy”, products_count: 0 }
- статус 422, если была ошибка валидации и
- ответ json: { errors: { name: [“can’t be blank”] } }
- Получение списка категорий (Read).
запрос с фронта - GET /categories
должен возвращать :- статус 200 и
- ответ json: [ { id: 1, name: “Food”, products_count: 9 }, { id: 2, name: “Drink”, products_count: 4 }],
где products_count - это количество продуктов в этой категории. Это значение должно меняться после добавления/удаления продуктов из таблицы базы данных.
- Изменение существующей категории (Update).
запрос с фронта - PUT /categories/3 с телом { category: { name: “Furniture” } }
должен возвращать:- статус 200, если все прошло успешно и
- ответ json: представление записи в базе данных. тут не важно получение количества продуктов.
- статус 422, если обновление прошло с ошибкой валидации и
- ответ json: { errors: { name: [“can’t be blank”] } }
- Удаление категории (Delete).
запрос с фронта - DELETE /categories/3
должен возвращать:- статус 200 и
- ответ json: например, представление удаленной категории, это не принципиально.
- Создание нового продукта в категории (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”] } }
- Получение списка продуктов для категории (Read).
запрос с фронта - GET /categories/2/products
должен возвращать:- статус 200 и
- ответ json: [ { id: 1, name: “Beef”, price: 42.69 }, { id: 2, name: “Cookies”, price: 69.42 } ]
- Изменение продукта (Update).
запрос с фронта - PUT /categories/3/products/3 с телом { product: { name: “Butter”, price: 5.8 } }
должен возвращать:- статус 200 если все прошло успешно и
- ответ json: представление записи из базы данных
- статус 422, если были ошибки валидации и
- ответ json: { errors: { name: [“can’t be blank”] } }
- Удаление продукта (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
Далее по порядку буду добавлять методы.
- Создание новой категории (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
Далее создам методы по каждому пункту из постановки задачи.
- Создание нового продукта в категории (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