Terrain
Opinionated toolkit for building CRUD APIs with Rails
Install
Add Terrain to your Gemfile:
gem 'terrain'Usage
- Error handling
- Resources
- Authorization
- Serialization
- Querying
- Filtering
- Ordering
- Pagination
- Relationships
- CRUD operations
- Config
Error handling
class ExampleController < ApplicationController
include Terrain::Errors
endRescues the following errors:
-
ActiveRecord::AssociationNotFoundError(400) -
Pundit::NotAuthorizedError(403) -
ActiveRecord::RecordNotFound(404) -
ActionController::RoutingError(404) -
ActiveRecord::RecordInvalid(422)
JSON responses are of the form:
{
"error": {
"key": "type_of_error",
"message": "Localized error message",
"details": "Optional details"
}
}To rescue a custom error with a similar response:
class ExampleController < ApplicationController
include Terrain::Errors
rescue_from MyError, with: :my_error
private
def my_error
error_response(:type_of_error, 500, { some: :details })
end
endResources
Suppose you have an Example model with foo, bar, and baz columns.
class ExampleController < ApplicationController
include Terrain::Resource
resource Example, permit: [:foo, :bar, :baz]
endThis sets up the typical resourceful Rails controller actions. Note that you'll still need to setup corresponding routes.
Authorization
Authorization is handled by Pundit. If the policy class for a given resource exists, each controller action calls the policy before proceeding with the operation. Authorization expects a current_user controller method to exist (otherwise nil is used as the pundit_user).
Serialization
Querying
Records of a given resource are queried by requesting the index action.
Filtering
Queries are scoped to the results returned from the resource_scope method. By default this returns all records, however, you can override it to further filter the results (i.e. based on query params, nested route params, etc.):
class ExampleController < ApplicationController
include Terrain::Resource
resource Example, permit: [:foo, :bar, :baz]
private
def resource_scope
scope = super
scope = scope.where(foo: params[:foo]) if params[:foo].present?
scope
end
endOrdering
You can pass an order param to reorder the response records. Specify a comma-separated list of fields and prefix the field with a - for descending order:
# corresponds to Example.order('foo', 'bar desc')
get :index, order: 'foo,-bar'Pagination
To request a range of records, specify the range in an HTTP header:
# Request the first 10 records
get :index, {}, { 'Range' => '0-9' }All responses include a Content-Range header that specifies the exact range returned as well as a total count of records. i.e.
Content-Range: 0-9/100
You can also pass open ended ranges such as 10- (i.e. skip the first 10 records).
Relationships
No model relationships are serialized in the response by default. To specify the set of relationships to be embedded in the response, pass a comma-separated list of relationships in the include param.
As an example, suppose we're querying for posts which each have many tags and belong to an author. We could embed those relationships with the following include param:
get :index, include: 'author,tags'Suppose now that the author also has a profile relationship. We could include the author, author profile and tags by passing:
get :index, include: 'author.profile,tags'Included relationships are automatically preloaded via the ActiveRecord includes method. The include param is also supported in show actions.
CRUD operations
You may need an action to perform additional steps beyond simple persistence. There are methods for performing CRUD operations that can be overridden (shown below with their default implementation):
class ExampleController < ApplicationController
include Terrain::Resource
resource Example, permit: [:foo, :bar, :baz]
private
def create_record
resource.create!(permitted_params)
end
def update_record(record)
record.update_attributes!(permitted_params)
record
end
def destroy_record(record)
record.delete
end
endConfig
Terrain.configure do |config|
# Maximum number of records returned
config.max_records = Float::INFINITY
end