Axel

Build Status

The building blocks for building a Sport Ngin Back-end API service:

  • add some error helpers (error object)
  • standardized XML/JSON envelope (metadata/error/result objects)
  • adding suppress_response_codes to params will force a 200 and will only show errors in the error object of the response body
  • standard controller responders to ensure JSON is always prefered, XML is acceptable, and the response always has a correct body that handles all errors

Installation

Add this line to your application's Gemfile:

source 'https://[email protected]/me/' # BELOW THE `source rubygems`
gem 'axel'

Then execute:

$ bundle

In your app/controllers/application_controller.rb (or wherever your base class is that will use these helpers) change to:

class ApplicationController < ActionController::Base
  include Axel::ControllerHelpers
end

Usage

Controller Specific Helpers

Errors

Errors object is available (as errors) from any controller:

errors.header_status         # suppress_response_codes param will set to 200 by default
errors << "Error message!"   # add error messages
errors.display               # { status: errors.status_code, messages: errors.messages }
errors.display?              # Display errors? (was there an error added or status changed?)
errors.messages
errors.messages = []
errors.status = :not_found
errors.status_code            # => 404
errors.status = 403
errors.status_code            # => 403
errors.new_error(status, *m)  # set status, add an error or list of errors (not in an array object)

# Can also set on the fly:
rescue_error status: :not_found, message: "Error!"
rescue_error status: :unproccessable_entity, messages: ["Error!", "Error2!"]

Metadata

Metadata object that will be placed on every outgoing response body. You can add to the object like so:

[:current_user] = current_user

# the body will then set this on the outgoing body:
# => { "metadata": { "current_user": "..." }, "result": "...", "...": "..." }

Responders

At the top of each controller (for APIs) should be:

respond_to_json_xml

This defines respond_to :json, :xml.

making these responders good to use for defaults:

respond_with_action :show   # Good for the end of a create to show the created object
render_action :show
respond_to_empty            # Render empty `result`, fill in `metadata`, `error` if necessary
render_empty

Resource finder

find_resource will automatically find a resource:

class PostsController < ApplicationController
  before_filter :find_resource
  def show
    respond_with
  end
end

curl http://localhost:3000/posts/1 # finds Post 1 and renders
curl http://localhost:3000/posts/1
# => {"metadata":{},"error":{"status":404,"messages":["Record not found"]},"result":null}

# can also customize the find_resource with `finder` and `value`
# were `finder` is column and `value` is the value of the column

Param helpers

query_params    # Only params on the query string
post_params     # POST params
object_params   # Either params under the object name (ie. {"user":".."} or all POST params
object_name     # singularized controller name for finding object_params

General workflow helpers

Helpers:

force_ssl!        # raises Errors::ForceSSL
drop_meta!        # we don't want the requester to get data like current_user, etc.
drop_meta?        # Did we call `drop_meta!`?
format            # The format passed from the request OR JSON
render_nil_format # This is for rendering nils in json or XML (XML is blank, JSON is "null")
safe_json_load    # If you've already manually rendered some to json this helper safely loads it to a hash for re-JSONing

Errors are rescued to make for easier API workflow and responding:

Axel::Errors::ForceSSL                # Drop meta
                                            # Status: Forbidden
                                            # Message: SSL is required

ActiveRecord::RecordNotFound                # Status: 404
                                            # Message: Record not found

Axel::Errors::NotAuthorized           # Status: 401
                                            # Message: User not authorized

ActiveModel::MassAssignmentSecurity::Error  # Status: 422
                                            # Message: Unacceptable parameter being used

Interservice Helpers

Some setup examples

Axel.config do |config|
  config.add_resource :user_service,
    :group,
    service: { url: "https://user-service.your-platform.com" }

  # Custom Path (otherwise defaults to plural of the resource_name (:user => "/users"))
  # config.add_resource :user_service,
  #   :user,
  #   service: { url: "https://user-service.your-platform.com" },
  #   attributes: [:user_name, :first_name],
  #   path: "owner"
end

class Group < Axel::ServiceResource::Base
  # Let's say your class doesn't match the configured resource, you can:
  resource_name :group

  # Setup fields (gets accessors, all available in #attributes)
  field :name
  field :owner_id
  field :owner_type
  field :uri

  route "/groups/mine", :mine
  route "/user/:user_id/groups", :by_user_id

  # Attached to every Group request, you can define instance defaults as well
  def self.default_request_options
    { headers: { "Accepts" => "stuff!", Authorization: "Bearer #{some_access_token}" } }
  end
end

group = Group.new name: "test", owner_id: 1, owner_type: "Organization", uri: "blargh"
group.save

my_groups = Group.mine
user_groups = Group.by_user_id(1)
user_groups = Group.by_user_id(user_id: 1)

a_group = Group.find(1)
class User < Axel::ServiceResource::Base
  has_many :personas
  has_one :email_address, class: Email, included: true
end

class Email < Axel::ServiceResource::Base
  belongs_to :user, find_nested: true
end

class Persona < Axel::ServiceResource::Base
  belongs_to :user
end

u = User.find(1)
u.personas            # => API call to /users/1/personas puts data in an array of Persona objects
u.email_address       # => Uses `email_address` in the User data to put into Email objects
Persona.find(1).user  # => API call to /users/#{persona.user_id} fills User object
Email.find(1).user    # => API call to /personas/1/user fills User Object

Some Chainable Query methods

Group.where(name: "test")
# => https://user-service.your-platform.com/groups?name=test

Group.all params: { name: "test" }, body: jsonified_stuffs, headers: {}, method: :post

Group.uri("https://user-service.dev/groups").where(name: "Jon").all(headers: {})
# => http://user-service.dev/groups?name=Jon

Group.uri("https://user-service.dev").at_path("/other_groups_path").where name: "test"
# => http://user-service.dev/other_groups_path?name=test

Group.where(name: "Jon").all(headers: {}).none
# => [] # Will always be empty array

Group.none.where(name: "Jon")
# => [] # can chain

Group.without_default_path.at_path("groupies")
# => https://user-service.your-platform.com/groupies

# Enumerable works!
Groups.where(name: "test").each { |g| puts e.attributes.inspect }
groups = Groups.where(name: "test").to_a

# drop cached JSON, then you can requery
groups.reload.where(owner_id: 1)

More General Usage

new_user.                 # => { .. } # Metadata section of JSON output
new_user.[:current_user]  # => { "id"=>1, "user_name"=>"admin", "first_name"=>"Happy", "last_name"=>"Gilmore", "uri"=>"http://user-service.dev/users/1"}
new_user.errors.status_code       # => 200
new_user.errors.messages          # => []
new_user.result                   # => { ... } # Main envelope JSON body

For more info on what the requester is and can do check out Axel::ServiceResource::Base

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request