Chewy Gem with Active Model Serializers in Ruby on Rails 5.2.3

I’ll talk about how to use Chewy gem and Active Model Serializers on Rails app. Chewy is one of ElasticSearch's clients and extends elasticsearch-ruby with various extensions and utilities. It is similar to elasticsearch-rails but I recently had an opportunity to use it in some projects so I’d like to share its usage.

To get started with your ElasticSearch app easily, you can use Docker and Docker-compose. I have MySQL 8.0, which is for Ruby on Rails database and ElasticSearch 6.4.2 and Kibana 6.4.2 as below.

Create a docker-compose.yml in your project folder:

version: '3'
services:
mysql:
image: mysql:8.0
volumes:
- mysqldata:/var/lib/mysql
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_as_ci --default-authentication-plugin=mysql_native_password
environment:
MYSQL_USER: root
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
elasticsearch:
image: elasticsearch:6.4.2
volumes:
- esdata:/usr/share/elasticsearch/data
environment:
- "ES_JAVA_OPTS=-Xms1g -Xmx1g"
ports:
- "9200:9200"
kibana:
image: kibana:6.4.2
ports:
- "5601:5601"
volumes:
mysqldata:
driver: local
esdata:
driver: local

And start with:

docker-compose up

Now you can access ElasticSearch with http://localhost:9200 and Kibana with http://localhost:5601 .

To keep the system gem clean, I’m going to create a Ruby on Rails project under my workspace.

Create a Gemfile first:

$ mkdir my-chewy-app
$ cd my-chewy-app
$ bundle init

Open Gemfile and uncomment out gem "rails" :

# frozen_string_literal: true
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails"

Install bundles:

bundle install --path vendor/bundle

Create a Rails app with MySQL and API mode:

bundle exec rails new . -B -d mysql --api

If you run into such an error with mysql2 when you bundle install:

Gem::Ext::BuildError: ERROR: Failed to build gem native extension.
....
An error occurred while installing mysql2 (0.5.2), and Bundler cannot continue.
Make sure that `gem install mysql2 -v '0.5.2' --source 'https://rubygems.org/'` succeeds before bundling.

Try this one:

bundle config --local build.mysql2 "--with-ldflags=-L/usr/local/opt/openssl/lib"

And install again:

bundle install --path vendor/bundle

Open config/database.yml and configure the local database:

# MySQL. Versions 5.1.10 and up are supported.
#
# Install the MySQL driver
# gem install mysql2
#
# Ensure the MySQL gem is defined in your Gemfile
# gem 'mysql2'
#
# And be sure to use new-style password hashing:
# https://dev.mysql.com/doc/refman/5.7/en/password-hashing.html
#
default: &default
adapter: mysql2
encoding: utf8
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: root
password: root
host: 127.0.0.1

Create a database:

bundle exec rails db:create

Now you’re done with setting up Rails app so start with dev server:

bundle exec rails s

Next, you’re going to install chewy and active model serializers.

Add chewy , active_model_serializers and kaminari to Gemfile :

gem 'chewy'
gem 'active_model_serializers'
gem 'kaminari' # for pagination

Install:

bundle

Create a client setting for chewy :

bundle exec rails g chewy:install

You can check out usages of chewy on the documentation.

For using ElasticSearch you will need to create an index.

Create a Users model first:

bundle exec rails g model User

Add some columns in db/migrate/xxxx_create_users.rb:

class CreateUser < ActiveRecord::Migration[5.2]
def change
create_table :users do |t|
t.string :name
t.string :email
t.string :phone
t.integer :status, null: false, default: 0

t.timestamps
end
end
end

Migrate it:

bundle exec rails db:migrate

Edit a model in app/models/user.rb:

class User < ApplicationRecord
update_index('users#user') { self }
enum status: { unconfirmed: 0, confirmed: 1 }
end

update_index('users#user') { self } means that specifying index, type, and back-reference for updating after user saves or destroy. Every index is observable by all the related models in Chewy so you don’t have to manually add code that updates the index.

Create a User Index in app/chewy/user_index.rb:

class UsersIndex < Chewy::Index
settings analysis: {
analyzer: {
email: {
tokenizer: 'keyword',
filter: ['lowercase']
}
}
}

define_type User do
field :name
field :email, analyzer: 'email'
field :phone
end
end

Okay, you finished creating an index.

To use an index that you made, add users controller in your project.

Generate a users controller:

bundle exec rails g controller users

Add users route in config/routes.rb:

Rails.application.routes.draw do
# For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
resources :users do
get :search, on: :collection
end
end

Edit controller:

class UsersController < ApplicationController
def search
@users = UsersIndex.query(query_string: { fields: [:name, :email, :phone], query: search_params[:query], default_operator: 'and' })

render json: @users.to_json, status: :ok
end

private

def search_params
params.permit(:query, :page, :per)
end
end

All right, you added search endpoint and connected to ElasticSearch using UserIndex. But you don’t have any users on your database and ElasticSearch so you’re going to create a sample user. To simplify, I just want you to create a user on console but you can also use factory-bot for variant users.

Boot console:

bundle exec rails c

Create a user:

irb(main):002:0> User.create(name: 'test1', email: 'test1@example.com', phone: '090111111')

And you can see it created user index at the same time:

(1.4ms)  SET NAMES utf8,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
(1.1ms) BEGIN
User Create (1.9ms) INSERT INTO `users` (`name`, `email`, `phone`, `created_at`, `updated_at`) VALUES ('test1', 'test1@example.com', '090111111', '2019-05-03 02:33:12', '2019-05-03 02:33:12')
(10.6ms) COMMIT
User Exists (1.5ms) SELECT 1 AS one FROM `users` WHERE `users`.`id` IN (1) LIMIT 1
User Load (1.6ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1)
UsersIndex::User Import (355.3ms) {:index=>1}
=> #<User id: 1, name: "test1", email: "test1@example.com", phone: "090111111", status: "unconfirmed", created_at: "2019-05-03 02:33:12", updated_at: "2019-05-03 02:33:12">

All right, you can access ElasticSearch as below.

http://localhost:3000/users/search?query=test1

And you’ll see like this:

[
{
attributes: {
id: "1",
name: "test1",
email: "test1@example.com",
phone: "090111111",
_score: 0.2876821,
_explanation: null
},
_data: {
_index: "users",
_type: "user",
_id: "1",
_score: 0.2876821,
_source: {
name: "test1",
email: "test1@example.com",
phone: "090111111"
}
}
}
]

Nice! You can get a response of ElasticSearch with user data and search for information. But actually, at this endpoint, you just might need a user data and you don’t want to use to_json and exclude only user attributes. For this purpose, you can use the Active Model Serializers.

You have already installed active model serializers above so next you add UserSerializer in app/serializers/user_serializer.rb .

Add UserSerializer:

class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email, :phone
end

Actually that’s it. And remove to_json from render json: @users.to_json, status in app/controllers/users_controller.rb :

class UsersController < ApplicationController
def search
@users = UsersIndex.query(query_string: { fields: [:name, :email, :phone], query: search_params[:query], default_operator: 'and' })

render json: @users, status: :ok
end

private

def search_params
params.permit(:query, :page, :per)
end
end

And access again:

http://localhost:3000/users/search?query=test1

I expect a JSON object only with the user but you’ve got an error like this:

Because in Chewy::Query it doesn’t have active model serialization build-in yet so you’ll have to monkey patch it.

Create config/initializers/chewy.rb and monkey patch it:

# config/initializers/chewy.rb

Chewy::Type.class_eval do
include ActiveModel::Serializers::JSON
end

Reboot server:

$ bundle exec rails s

Access again:

http://localhost:3000/users/search?query=test1

You should get like this:

[
{
id: "1",
name: "test1",
email: "test1@example.com",
phone: "090111111"
}
]

Monkey patching doesn’t look like a good solution but for now, you cannot convert query results into a JSON object directly.

For now, it looks like working totally fine but I want it to be more generic and extendable.

To simplify the controller you can implement a search interface with ActiveModel.

Create a app/searches/user_search.rb :

# user_search.rb
# frozen_string_literal: true

class UserSearch
include ActiveModel::Model

DEFAULT_PER_PAGE = 10
DEFAULT_PAGE = 0

attr_accessor :query, :page, :per

def search
[query_string].compact.reduce(&:merge).page(page_num).per(per_page)
end

def query_string
index.query(query_string: { fields: [:name, :email, :phone], query: query, default_operator: 'and' }) if query.present?
end

private

def index
UsersIndex
end

def page_num
page || DEFAULT_PAGE
end

def per_page
per || DEFAULT_PER_PAGE
end
end

And introduce UserSearch to app/controllers/users_controller :

class UsersController < ApplicationController
def search
user_search = UserSearch.new(search_params)
@users = user_search.search

render json: @users, status: :ok
end

private

def search_params
params.permit(:query, :page, :per)
end
end

I think it’s much simpler and separated each concern of controller and form.

I talked about how to use Chewy with Active Model Serializer for the ElasticSearch app. If you want to use ElasticSearch query directly, you can go to Kibana console and try out a bunch of options.

I hope this article will get you interested.

Web Developer, TypeScript, React, React Native, Vue.js, Go, Swift, and Ruby on Rails https://manatoworks.me/

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store