Customizing the API

Introduction to the REST API

Solidus comes with a complete REST API that allows you to manage all aspects of your store. In some cases, you may want to extend the API's functionality by adding new endpoints and customizing the existing ones. Like all other parts of Solidus, the API is a Rails engine, which means you can use the regular tools at your disposal to customize its behavior.

The API is also used by the default Solidus backend to perform certain tasks without reloading the page. If you are modifying an endpoint's input schema or heavily customizing its output, make sure you are not accidentally breaking your backend! If in doubt, you can always create a new endpoint for your purposes.

In this guide, we'll see how to implement a quick-and-dirty API for customers to "like" a certain product, so that you know which products customer like the most. We'll implement a new endpoint for liking a product and we'll add a likes_count attribute to the existing products API.

Let's get started!

Designing your resources

First of all, we need to make sure users can somehow tell us that they like a product. Because we want this to happen without users having to reload the page, we'll do it via the API.

We could just a likes_count column to the spree_products table, but in our case we also want to know which users liked which products, so that a product cannot be liked twice by the same user and we can personalize the user's recommendations based on the products they liked. We will also want to make sure that unauthenticated users cannot like a product, because we wouldn't be able to associate that like to a specific customer.

Let's start by creating a new model, ProductLike, which we'll use to store the user-product relationship:

$ rails g model ProductLike user:belongs_to product:belongs_to

We'll need to edit the model generated by this command to specify the full namespace of our associated models:

app/models/product_like.rb
class ProductLike < ApplicationRecord
belongs_to :product, class_name: 'Spree::Product'
belongs_to :user, class_name: 'Spree::User'
end

We'll also have to edit the migration manually to specify the correct table names for the foreign keys:

class CreateProductLikes < ActiveRecord::Migration[6.0]
def change
create_table :product_likes do |t|
t.integer :user_id, null: false
t.integer :product_id, null: false
t.foreign_key :spree_users, column: :user_id, on_delete: :cascade
t.foreign_key :spree_products, column: :product_id, on_delete: :cascade
t.timestamps
end
end
end

Once we're done, we can run the migration with the usual command:

$ rails db:migrate

Finally, we will add a uniqueness validation to our model, along with a factory and a model spec:

product_like.rb
product_likes.rb
product_like_spec.rb
product_like.rb
app/models/product_like.rb
class ProductLike < ApplicationRecord
# ...
validates :user, uniqueness: { scope: :product_id }
end
product_likes.rb
spec/factories/product_likes.rb
FactoryBot.define do
factory :product_like do
association :user
association :product
end
end
product_like_spec.rb
spec/models/product_like_spec.rb
RSpec.describe ProductLike do
it 'has a valid factory' do
expect(build(:product_like)).to be_valid
end
it 'validates the uniqueness of the user/product pair' do
existing_product_like = create(:product_like)
product_like = build(:product_like,
user: existing_product_like.user,
product: existing_product_like.product,
)
expect(product_like).not_to be_valid
end
end

You can also configure FactoryBot to auto-generate the factory when you create a new model. For more information, refer to the FactoryBot documentation.

Now that we have our model in place, we're ready to start creating an API endpoint for liking products!

Creating a new endpoint

Implementing a new API endpoint is fairly simple: all we have to do is create a new controller along with its actions, routes and views, just like we would do in a regular Rails application. For our use case, we'll want to create a ProductLikesController with a create action that allows us to like a product.

We could also add a like action to the existing Spree::Api::ProductsController. However, it's recommended to follow RESTful resource naming when creating new API endpoints. This makes our API easier to understand, consume and maintain.

Let's create our controller, along with its view, route a request spec to test it all:

product_likes_controller.rb
show.json.jbuilder
routes.rb
product_likes_spec.rb
product_likes_controller.rb
app/controllers/api/product_likes_controller.rb
module Api
class ProductLikesController < Spree::Api::BaseController
def create
@product = find_product(params[:product_id])
@like = ProductLike.new(product: @product, user: current_api_user)
if @like.save
render :show, status: :created
else
invalid_resource!(@like)
end
end
end
end

In our controller, we are relying on a few helpers Solidus provides out of the box:

  • find_product retrieves a product by its numeric ID or slug.

  • current_api_user retrieves the currently authenticated user (by default, all API requests require authentication).

  • invalid_resource! generates a 422 response that exposes all the error messages on an ActiveModel-compliant object.

show.json.jbuilder
app/views/api/product_likes/show.json.jbuilder
json.user_id(@like.user_id)
json.partial!("spree/api/products/product", product: @like.product)

In the view, we are using JBuilder to create a JSON representation of our ProductLike instance. The user ID is represented as an integer, while the product is transformed into a full JSON object by using Solidus' _product.json.jbuilder partial.

routes.rb
config/routes.rb
# ...
Spree::Core::Engine.routes.draw do
namespace :api, defaults: { format: 'json' } do
resources :products do
resource :product_like, only: :create
end
end
end

In the route, you may notice we are using a singleton resource (resource :product_like) rather than a collection (resource :product_likes). This is because a user may only have one like for a product. We are also limiting the routes for that resource to the :create action, since we are not going to implement the others.

product_likes_spec.rb
spec/requests/product_likes_spec.rb
RSpec.describe '/api/products/:slug/product_likes' do
before { stub_authentication! }
describe 'POST /' do
context 'when the user has not already liked the product' do
it 'responds with 204 No Content' do
product = create(:product)
post api_product_product_likes_path(product)
expect(response.status).to eq(204)
end
it 'likes the product' do
product = create(:product)
post api_product_product_likes_path(product)
expect(ProductLike.count).to eq(1)
end
end
context 'when the user has already liked the product' do
it 'responds with 422 Unprocessable Entity' do
product_like = create(:product_like, user: current_api_user)
post api_product_product_likes_path(product)
expect(response.status).to eq(422)
end
it 'does not re-like the product' do
product_like = create(:product_like, user: current_api_user)
post api_product_product_likes_path(product)
expect(ProductLike.count).to eq(1)
end
end
end
end

In the spec, we are using the stub_authentication! and current_api_user helpers, which stub Solidus' authentication mechanism and return the currently authenticated API user.

Solidus provides a lot more partials, helpers and utilities for implementing and testing your API requests. Take a look at the source code of solidus_api to see what's available!

At this point, your request spec should be passing, meaning you can integrate it in your frontend and allow users to like products!

Extending existing resources

As a next step, we'll add a likes_count to the Spree::Product model and expose it in our API. In order to do this, we first need to add the column to the spree_products table:

$ rails g migration AddLikesCountToSpreeProducts likes_count:integer

We will also need to make sure the likes_count column is automatically updated with the number of users who have liked the product. In order to do this, we can use ActiveRecord's excellent counter cache feature. Let's modify the belongs_to :product association by enabling the option:

class ProductLike < ApplicationRecord
# ...
belongs_to :product, class_name: 'Spree::Product', counter_cache: true
# ...
end

ActiveRecord should now start keeping the number of likes in the likes_count column.

In order to expose this field to API clients, we'll need to add a JSON field to the products API. We could just copy-paste the product.json.jbuilder view from Solidus and add the field there, but then we would need to remember to update our custom view every time the original view is changed.

Instead, Solidus provides a more manageable way to add attributes to API resources via the ApiHelpers module. Let's see how we can do it and test it:

spree.rb
products_spec.rb
spree.rb
config/initializers/spree.rb
# ...
Spree::Api::ApiHelpers.product_attributes << :likes_count
products_spec.rb
spec/requests/api/products_spec.rb
RSpec.describe '/api/products' do
before { stub_authentication! }
describe 'GET /:slug' do
it 'exposes the likes_count field' do
product = create(:product)
get spree.api_product_path(product)
parsed_response = JSON.parse(response.body)
expect(parsed_response).to match(a_hash_including(
'product' => a_hash_including('likes_count' => 0),
))
end
end
end

That's all we need! We have created a new API resource and implemented a new endpoint to manipulate it, and we have seen how to add fields to an existing API resource. If you feel adventurous, how about trying to implement an endpoint for removing an existing product like?

Managing API documentation

This section still needs to be written.