Power API
It's a Rails engine that gathers a set of gems and configurations designed to build incredible REST APIs.
These gems are:
- API Pagination: to handle issues related to pagination.
- ActiveModelSerializers: to handle API response format.
- Ransack: to handle filters.
- Responders: to dry up your API.
- Rswag: to test and document the API.
- Simple Token Authentication: to authenticate your resources.
- Versionist: to handle the API versioning.
To understand what this gem does, it is recommended to read first about those mentioned above.
Content
- Power API
- Content
- Installation
- Usage
- Initial Setup
- Exposed API mode
- Command options:
--authenticated-resources
- Command options:
- Internal API mode
- Version Creation (exposed mode only)
- Controller Generation (exposed and internal modes)
- Command options (valid for internal and exposed modes):
--attributes
--controller-actions
--version-number
--use-paginator
--allow-filters
--authenticate-with
--owned-by-authenticated-resource
--parent-resource
- Command options (valid for internal and exposed modes):
- Inside the gem
- The
Api::Error
concern - The
Api::Deprecated
concern - The
ApiResponder
- The
PowerApi::ApplicationHelper#serialize_resource
helper method
- The
- Testing
- Publishing
- Contributing
- Credits
- License
Installation
Add to your Gemfile:
gem 'power_api'
group :development, :test do
gem 'factory_bot_rails'
gem 'rspec-rails'
gem 'rswag-specs'
gem 'rubocop'
gem 'rubocop-rspec'
end
Then,
bundle install
Usage
Initial Setup
You must run the following command to have the initial configuration:
rails generate power_api:install
After doing this you will get:
-
A base controller for your API under
/your_app/app/controllers/api/base_controller.rb
class Api::BaseController < PowerApi::BaseController end
Here you should include everything common to all your APIs. It is usually empty because most of the configuration comes in the
PowerApi::BaseController
that is inside the gem. -
Some initializers:
-
/your_api/config/initializers/active_model_serializers.rb
:ActiveModelSerializers.config.adapter = :json
-
/your_api/config/initializers/api_pagination.rb
:ApiPagination.configure do |config| config.paginator = :kaminari # more options... end
We use what comes by default and kaminari as pager.
-
After running the installer you must choose an API mode.
Exposed API mode
Use this mode if your API will be accessed by multiple clients or if your API is served somewhere other than your client application.
You must run the following command to have the exposed API mode configuration:
rails generate power_api:exposed_api_config
After doing this you will get:
-
A base controller for the first version of your API under
/your_api/app/controllers/api/exposed/v1/base_controller.rb
class Api::Exposed::V1::BaseController < Api::BaseController before_action do self.namespace_for_serializer = ::Api::Exposed::V1 end end
Everything related to version 1 of your API must be included here.
-
Some initializers: We configure the first version to be seen in the documentation view.
-
/your_api/config/initializers/simple_token_authentication.rb
:We use the default options.SimpleTokenAuthentication.configure do |config| # options... end
-
-
A modified
/your_api/config/routes.rb
file:Rails.application.routes.draw do scope path: '/api' do api_version(module: 'Api::Exposed::V1', path: { value: 'v1' }, defaults: { format: 'json' }) do end end # ... end
Command options:
--authenticated-resources
Use this option if you want to configure Simple Token Authentication for one or more models.
rails g power_api:install --authenticated-resources=user
Running the above code will generate, in addition to everything described in the initial setup, the following:
-
The Simple Token Authentication initializer
/your_api/config/initializers/simple_token_authentication.rb
-
An edited version of the User model with the configuration needed for Simple Token Authentication.
class User < ApplicationRecord acts_as_token_authenticatable # more code... end
-
The migration
/your_api/db/migrate/20200228173608_add_authentication_token_to_users.rb
to add theauthentication_token
to your users table.
Internal API mode
Use this mode if your API and your client app will be served on the same place.
You must run the following command to have the internal API mode configuration:
rails generate power_api:internal_api_config
After doing this you will get:
-
A base controller for your internal API under
/your_api/app/controllers/api/internal/base_controller.rb
class Api::Internal::BaseController < Api::BaseController before_action do self.namespace_for_serializer = ::Api::Internal end end
Anything shared by the internal API controllers should go here.
-
A modified
/your_api/config/routes.rb
file:namespace :api, defaults: { format: :json } do namespace :internal do end end
-
An empty directory indicating where you should put your serializers:
/your_api/app/serializers/api/internal/.gitkeep
Version Creation (exposed mode only)
To add a new version you must run the following command:
rails g power_api:version VERSION_NUMBER
Example:
rails g power_api:version 2
Doing this will add the same thing that was added for version one in the initial setup but this time for the number version provided as parameter.
Controller Generation (exposed and internal modes)
To add a controller you must run the following command:
rails g power_api:controller MODEL_NAME [options]
Example:
rails g power_api:controller blog
Assuming we have the following model,
class Blog < ApplicationRecord
# == Schema Information
#
# Table name: blogs
#
# id :bigint(8) not null, primary key
# title :string(255)
# body :text(65535)
# created_at :datetime not null
# updated_at :datetime not null
#
end
after doing this you will get:
-
A modified
/your_api/config/routes.rb
file with the new resource:- Exposed mode:
Rails.application.routes.draw do scope path: '/api' do api_version(module: 'Api::V1', path: { value: 'v1' }, defaults: { format: 'json' }) do resources :blogs end end end
- Internal mode:
Rails.application.routes.draw do namespace :api, defaults: { format: :json } do namespace :internal do resources :blogs end end end
- Exposed mode:
-
A controller under
/your_api/app/controllers/api/exposed/v1/blogs_controller.rb
class Api::Exposed::V1::BlogsController < Api::Exposed::V1::BaseController def index respond_with Blog.all end def show respond_with blog end def create respond_with Blog.create!(blog_params) end def update respond_with blog.update!(blog_params) end def destroy respond_with blog.destroy! end private def blog @blog ||= Blog.find_by!(id: params[:id]) end def blog_params params.require(:blog).permit( :id, :title, :body, ) end end
With internal mode the file path will be:
/your_api/app/controllers/api/internal/blogs_controller.rb
and the class name:Api::Internal::BlogsController
-
A serializer under
/your_api/app/serializers/api/exposed/v1/blog_serializer.rb
class Api::Exposed::V1::BlogSerializer < ActiveModel::Serializer type :blog attributes( :id, :title, :body, :created_at, :updated_at ) end
With internal mode the file path will be:
/your_api/app/serializers/api/internal/blog_serializer.rb
and the class name:Api::Internal::BlogSerializer
-
A spec file under
/your_api/spec/integration/api/exposed/v1/blogs_spec.rb
require 'rails_helper' RSpec.describe 'Api::Exposed::V1::BlogsControllers', type: :request do describe 'GET /index' do let!(:blogs) { create_list(:blog, 5) } let(:collection) { JSON.parse(response.body)['blogs'] } let(:params) { {} } def perform get '/api/v1/blogs', params: params end before do perform end it { expect(collection.count).to eq(5) } it { expect(response.status).to eq(200) } end describe 'POST /create' do let(:params) do { blog: { title: 'Some title', body: 'Some body' } } end let(:attributes) do JSON.parse(response.body)['blog'].symbolize_keys end def perform post '/api/v1/blogs', params: params end before do perform end it { expect(attributes).to include(params[:blog]) } it { expect(response.status).to eq(201) } context 'with invalid attributes' do let(:params) do { blog: { title: nil } } end it { expect(response.status).to eq(400) } end end describe 'GET /show' do let(:blog) { create(:blog) } let(:blog_id) { blog.id.to_s } let(:attributes) do JSON.parse(response.body)['blog'].symbolize_keys end def perform get '/api/v1/blogs/' + blog_id end before do perform end it { expect(response.status).to eq(200) } context 'with resource not found' do let(:blog_id) { '666' } it { expect(response.status).to eq(404) } end end describe 'PUT /update' do let(:blog) { create(:blog) } let(:blog_id) { blog.id.to_s } let(:params) do { blog: { title: 'Some title', body: 'Some body' } } end let(:attributes) do JSON.parse(response.body)['blog'].symbolize_keys end def perform put '/api/v1/blogs/' + blog_id, params: params end before do perform end it { expect(attributes).to include(params[:blog]) } it { expect(response.status).to eq(200) } context 'with invalid attributes' do let(:params) do { blog: { title: nil } } end it { expect(response.status).to eq(400) } end context 'with resource not found' do let(:blog_id) { '666' } it { expect(response.status).to eq(404) } end end describe 'DELETE /destroy' do let(:blog) { create(:blog) } let(:blog_id) { blog.id.to_s } def perform delete '/api/v1/blogs/' + blog_id end before do perform end it { expect(response.status).to eq(204) } context 'with resource not found' do let(:blog_id) { '666' } it { expect(response.status).to eq(404) } end end end
With internal mode the file path will be:
your_api/spec/integration/api/internal/blogs_spec.rb
and the class name:Api::Internal::BlogsControllers
Command options (valid for internal and exposed modes):
--attributes
Use this option if you want to choose which attributes of your model to add to the API response.
rails g power_api:controller blog --attributes=title
When you do this, you will see permited_params, serializers, etc. showing only the selected attributes
For example, the serializer under /your_api/app/serializers/api/exposed/v1/blog_serializer.rb
will show:
class Api::Exposed::V1::BlogSerializer < ActiveModel::Serializer
type :blog
attributes(
:title,
)
end
--controller-actions
Use this option if you want to choose which actions will be included in the controller.
rails g power_api:controller blog --controller-actions=show destroy
When you do this, you will see that only relevant code is generated in controller, tests and routes.
For example, the controller would only include the show
and destroy
actions and wouldn't include the blog_params
method:
class Api::Exposed::V1::BlogController < Api::Exposed::V1::BaseController
def show
respond_with blog
end
def destroy
respond_with blog.destroy!
end
private
def blog
@blog ||= Blog.find_by!(id: params[:id])
end
end
--version-number
Use this option if you want to decide which version the new controller will belong to.
rails g power_api:controller blog --version-number=2
Important! When working with exposed api you should always specify the version, otherwise the controller will be generated for the internal api mode.
--use-paginator
Use this option if you want to paginate the index endpoint collection.
rails g power_api:controller blog --use-paginator
The controller under /your_api/app/controllers/api/exposed/v1/blogs_controller.rb
will be modified to use the paginator like this:
class Api::Exposed::V1::BlogsController < Api::Exposed::V1::BaseController
def index
respond_with paginate(Blog.all)
end
# more code...
end
Due to the API Pagination gem the X-Total
, X-Per-Page
and X-Page
headers will be added to the answer. The parameters params[:page][:number]
and params[:page][:size]
can also be passed through the query string to access the different pages.
Because the AMS gem is set with "json api" format, links related to pagination will be added to the API response.
--allow-filters
Use this option if you want to filter your index endpoint collection with Ransack
rails g power_api:controller blog --allow-filters
The controller under /your_api/app/controllers/api/exposed/v1/blogs_controller.rb
will be modified like this:
class Api::Exposed::V1::BlogsController < Api::Exposed::V1::BaseController
def index
respond_with filtered_collection(Blog.all)
end
# more code...
end
The filtered_collection
method is defined inside the gem and uses ransack below.
You will be able to filter the results according to this: https://github.com/activerecord-hackery/ransack#search-matchers
For example:
http://localhost:3000/api/v1/blogs?q[id_gt]=22
to search blogs with id greater than 22
--authenticate-with
Use this option if you want to have authorized resources.
To learn more about the authentication method used please read more about Simple Token Authentication gem.
rails g power_api:controller MODEL_NAME --authenticate-with=ANOTHER_MODEL_NAME
Example:
rails g power_api:controller blog --authenticate-with=user
When you do this your controller will have the following line:
class Api::Exposed::V1::BlogsController < Api::Exposed::V1::BaseController
acts_as_token_authentication_handler_for User, fallback: :exception
# mode code...
end
With internal mode a
before_action :authenticate_user!
statement will be added instead ofacts_as_token_authentication_handler_for
in order to work with devise gem directly.
In addition, the specs under /your_api/spec/integration/api/v1/blogs_spec.rb
will add tests related with authorization.
response '401', 'user unauthorized' do
let(:user_token) { 'invalid' }
run_test!
end
--owned-by-authenticated-resource
If you have an authenticated resource you can choose your new resource be owned by the authenticated one.
rails g power_api:controller blog --authenticate-with=user --owned-by-authenticated-resource
The controller will look like this:
class Api::Exposed::V1::BlogsController < Api::Exposed::V1::BaseController
acts_as_token_authentication_handler_for User, fallback: :exception
def index
respond_with blogs
end
def show
respond_with blog
end
def create
respond_with blogs.create!(blog_params)
end
def update
respond_with blog.update!(blog_params)
end
def destroy
respond_with blog.destroy!
end
private
def blog
@blog ||= blogs.find_by!(id: params[:id])
end
def blogs
@blogs ||= current_user.blogs
end
def blog_params
params.require(:blog).permit(
:id,
:title,
:body
)
end
end
As you can see the resource (blog
) will always come from the authorized one (current_user.blogs
)
To make this possible, the models should be related as follows:
class Blog < ApplicationRecord
belongs_to :user
end
class User < ApplicationRecord
has_many :blogs
end
--parent-resource
Assuming we have the following models,
class Blog < ApplicationRecord
has_many :comments
end
class Comment < ApplicationRecord
belongs_to :blog
end
we can run the following code to handle nested resources:
rails g power_api:controller comment --attributes=body --parent-resource=blog
Running the previous code we will get:
-
The controller under
/your_api/app/controllers/api/exposed/v1/comments_controller.rb
:class Api::Exposed::V1::CommentsController < Api::Exposed::V1::BaseController def index respond_with comments end def show respond_with comment end def create respond_with comments.create!(comment_params) end def update respond_with comment.update!(comment_params) end def destroy respond_with comment.destroy! end private def comment @comment ||= Comment.find_by!(id: params[:id]) end def comments @comments ||= blog.comments end def blog @blog ||= Blog.find_by!(id: params[:blog_id]) end def comment_params params.require(:comment).permit( :id, :body ) end end
As you can see the
comments
used onindex
andcreate
will always come fromblog
(the parent resource) -
A modified
/your_api/config/routes.rb
file with the nested resource:Rails.application.routes.draw do scope path: '/api' do api_version(module: 'Api::V1', path: { value: 'v1' }, defaults: { format: 'json' }) do resources :comments, only: [:show, :update, :destroy] resources :blogs do resources :comments, only: [:index, :create] end end end end
Note that the options:
--parent-resource
and--owned-by-authenticated-resource
cannot be used together.
Inside the gem
module PowerApi
class BaseController < ApplicationController
include Api::Error
include Api::Deprecated
self.responder = ApiResponder
respond_to :json
end
end
The PowerApi::BaseController
class that exists inside this gem and is inherited by the base class of your API (/your_app/app/controllers/api/base_controller.rb
) includes functionality that I will describe bellow:
The Api::Error
concern
This module handles common exceptions like:
ActiveRecord::RecordNotFound
ActiveModel::ForbiddenAttributesError
ActiveRecord::RecordInvalid
PowerApi::InvalidVersion
Exception
If you want to handle new errors, this can be done by calling the respond_api_error
method in the base class of your API like this:
class Api::BaseController < PowerApi::BaseController
rescue_from "MyCustomErrorClass" do |exception|
respond_api_error(:bad_request, message: "some error message", detail: exception.message)
end
end
The Api::Deprecated
concern
This module is useful when you want to mark endpoints as deprecated.
For example, if you have the following controller:
class Api::Exposed::V1::CommentsController < Api::Exposed::V1::BaseController
deprecate :index
def index
respond_with comments
end
# more code...
end
And then in your browser you execute: GET /api/v1/comments
, you will get a Deprecated: true
response header.
This is useful to notify your customers that an endpoint will not be available in the next version of the API.
The ApiResponder
It look like this:
class ApiResponder < ActionController::Responder
def api_behavior
raise MissingRenderer.new(format) unless has_renderer?
if delete?
head :no_content
elsif post?
display resource, status: :created
else
display resource
end
end
end
As you can see, this simple Responder handles the API response based on the HTTP verbs.
The PowerApi::ApplicationHelper#serialize_resource
helper method
This helper method is useful if you want to serialize ActiveRecord resources to use in your views. For example, you can do:
<pre>
<%= serialize_resource(@resource, @options) %>
</pre>
To get:
{"id":1,"title":"lean","body":"bla","createdAt":"2022-01-08T18:15:46.624Z","updatedAt":"2022-01-08T18:15:46.624Z","portfolioId":null}
The @resource
parameter must be an ActiveRecord instance (ApplicationRecord
) or collection (ActiveRecord_Relation
).
The @options
parameter must be a Hash
and can contain the options you commonly use with Active Model Serializer gem (fields
, transform_key
, etc.) and some others:
-
include_root
: to get something like:{"id":1,"title":"lean"}
or{"blog": {"id":1,"title":"lean"}}
. -
output_format
: can be:hash
or:json
.
Testing
To run the specs you need to execute, in the root path of the gem, the following command:
bundle exec guard
You need to put all your tests in the /power_api/spec/dummy/spec/
directory.
Publishing
On master/main branch...
- Change
VERSION
inlib/power_api/version.rb
. - Change
Unreleased
title to current version inCHANGELOG.md
. - Run
bundle install
. - Commit new release. For example:
Releasing v0.1.0
. - Create tag. For example:
git tag v0.1.0
. - Push tag. For example:
git push origin v0.1.0
.
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request
Credits
Thank you contributors!
Power API is maintained by platanus.
License
Power API is © 2022 platanus, spa. It is free software and may be redistributed under the terms specified in the LICENSE file.