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::Errorconcern - The
Api::Deprecatedconcern - The
ApiResponder - The
PowerApi::ApplicationHelper#serialize_resourcehelper 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'
endThen,
bundle installUsage
Initial Setup
You must run the following command to have the initial configuration:
rails generate power_api:installAfter doing this you will get:
-
A base controller for your API under
/your_app/app/controllers/api/base_controller.rbclass 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::BaseControllerthat 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_configAfter 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.rbclass 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.rbfile: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=userRunning 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.rbto add theauthentication_tokento 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_configAfter doing this you will get:
-
A base controller for your internal API under
/your_api/app/controllers/api/internal/base_controller.rbclass 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.rbfile: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_NUMBERExample:
rails g power_api:version 2Doing 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 blogAssuming 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
#
endafter doing this you will get:
-
A modified
/your_api/config/routes.rbfile 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.rbclass 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.rband the class name:Api::Internal::BlogsController -
A serializer under
/your_api/app/serializers/api/exposed/v1/blog_serializer.rbclass 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.rband the class name:Api::Internal::BlogSerializer -
A spec file under
/your_api/spec/integration/api/exposed/v1/blogs_spec.rbrequire '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.rband 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=titleWhen 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 destroyWhen 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=2Important! 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-paginatorThe 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...
endDue 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-filtersThe 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...
endThe 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_NAMEExample:
rails g power_api:controller blog --authenticate-with=userWhen 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...
endWith internal mode a
before_action :authenticate_user!statement will be added instead ofacts_as_token_authentication_handler_forin 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-resourceThe 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
endAs 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
endwe can run the following code to handle nested resources:
rails g power_api:controller comment --attributes=body --parent-resource=blogRunning 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
commentsused onindexandcreatewill always come fromblog(the parent resource) -
A modified
/your_api/config/routes.rbfile 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-resourceand--owned-by-authenticated-resourcecannot be used together.
Inside the gem
module PowerApi
class BaseController < ApplicationController
include Api::Error
include Api::Deprecated
self.responder = ApiResponder
respond_to :json
end
endThe 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::RecordNotFoundActiveModel::ForbiddenAttributesErrorActiveRecord::RecordInvalidPowerApi::InvalidVersionException
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
endThe 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...
endAnd 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
endAs 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:hashor:json.
Testing
To run the specs you need to execute, in the root path of the gem, the following command:
bundle exec guardYou need to put all your tests in the /power_api/spec/dummy/spec/ directory.
Publishing
On master/main branch...
- Change
VERSIONinlib/power_api/version.rb. - Change
Unreleasedtitle 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.