Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial commit.

  • Loading branch information...
commit b9939c8db6fe40e908eab7a8d8044c0426a0ada6 0 parents
@hopsoft authored
2  .gitignore
@@ -0,0 +1,2 @@
+.rvmrc
+.tmp
9 Gemfile
@@ -0,0 +1,9 @@
+source "https://rubygems.org"
+
+gem "activesupport"
+
+group :test do
+ gem "minitest", "2.12.0"
+ gem "pry"
+ gem "turn"
+end
28 Gemfile.lock
@@ -0,0 +1,28 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ activesupport (3.2.3)
+ i18n (~> 0.6)
+ multi_json (~> 1.0)
+ ansi (1.4.2)
+ coderay (1.0.6)
+ i18n (0.6.0)
+ method_source (0.7.1)
+ minitest (2.12.0)
+ multi_json (1.2.0)
+ pry (0.9.8.4)
+ coderay (~> 1.0.5)
+ method_source (~> 0.7.1)
+ slop (>= 2.4.4, < 3)
+ slop (2.4.4)
+ turn (0.9.4)
+ ansi
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ activesupport
+ minitest (= 2.12.0)
+ pry
+ turn
74 README.md
@@ -0,0 +1,74 @@
+# Coast
+
+### ...if only the REST of life were this easy
+
+Coast provides resourceful behavior for Rails controllers.
+
+Simply include a single module in your controller and get these actions for free.
+
+* new
+* edit
+* index
+* show
+* create
+* update
+* destroy
+
+But wait... there's more.
+
+* Support for **html, xml, and json** formats
+* Sinatra like **DSL** for hooking into those callbacks
+* **Implicit security** via authorization with your favorite libs *...such as CanCan*
+
+## TL;DR
+
+**Quick-start for the lazy**
+
+```bash
+$gem install coast
+```
+
+```ruby
+# config/routes.rb
+Lazy::Application.routes.draw do
+ resources :bums
+end
+```
+
+```# app/controllers/bums_controller.rb
+class BumsController < ApplicationController
+ include Coast
+end
+```
+
+Congratulations... you now have a RESTful API for **lazy bums**.
+
+## Callbacks
+
+Coast uses a Sinatra like DSL to provide you with access points into the action lifecycle.
+The following hooks are supported for each action.
+
+* before *- before any other action logic is performed*
+* respond_to *- after authorization and db work but before rendering*
+* after *- after all other logic*
+
+Here are some examples of how to use this stuff.
+
+```ruby
+# soon...
+```
+
+## Authorization
+
+
+
+
+
+# The MIT License (MIT)
+Copyright (c) 2012 Nathan Hopkins
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
5 Rakefile
@@ -0,0 +1,5 @@
+require 'rake/testtask'
+
+Rake::TestTask.new do |t|
+ t.pattern = "spec/*_spec.rb"
+end
12 coast.gemspec
@@ -0,0 +1,12 @@
+Gem::Specification.new do |s|
+ s.name = "coast"
+ s.version = "1.0.0"
+ s.date = "2012-04-06"
+ s.summary = "Coast"
+ s.description = "Resourceful behavior for Rails controllers with a Sinatra like DSL."
+ s.authors = [ "Nathan Hopkins" ]
+ s.email = "nate.hop@gmail.com"
+ s.files = [ "lib/coast.rb" ]
+ s.homepage ="https://github.com/hopsoft/coast"
+ s.add_dependency("activesupport")
+end
266 lib/coast.rb
@@ -0,0 +1,266 @@
+# Makes any controller resourceful by providing the following actions:
+# * new
+# * edit
+# * index
+# * show
+# * create
+# * update
+# * destroy
+#
+# There are 3 callbacks that you can leverage to manage the RESTful behavior:
+# * before - Happens before any logic, just like a Rails before_filter.
+# Note that this callback is invoked before any authorization is applied
+# * respond_to - Happens after CRUD operations but before rendering a response
+# * after - Happens after any logic, just like a Rails after_filter
+#
+# You can hook into the controller's lifecycle like so:
+#
+# before([action]) do
+# # logic here
+# end
+#
+# respond_to([action]) do
+# # logic here
+# end
+#
+# after([action]) do
+# # logic here
+# end
+#
+# Resourceful leans heavily on Rails naming conventions.
+# If you are using Rails naming convetions, all this power is yours for free.
+module Coast
+
+ module ClassMethods
+
+ def set_authorize_method(arg)
+ @authorize_method = arg
+ end
+ alias :authorize_method= :set_authorize_method
+
+ def authorize_method
+ @authorize_method ||= :abstract_authorize
+ end
+
+ def set_resourceful_model(arg)
+ @resourceful_model = arg
+ end
+ alias :resourceful_model= :set_resourceful_model
+
+ def resourceful_model
+ return @resourceful_model if @resourceful_model
+
+ # try to determine the model based on convention
+ name = self.name.gsub(/Controller$/i, "").classify
+ # require the file to ensure the constant will be defined
+ require "#{RAILS_ROOT}/app/models/#{name.underscore}" rescue nil
+ @resourceful_model = Object.const_get(name)
+ end
+
+ def before(action, &callback)
+ define_method "before_#{action}", &callback
+ end
+
+ def respond_to(action, &callback)
+ define_method "respond_to_#{action}", &callback
+ end
+
+ def after(action, &callback)
+ define_method "after_#{action}", &callback
+ end
+
+ end
+
+ def self.included(mod)
+ mod.extend(ClassMethods)
+ end
+
+ def abstract_authorize(*args); end
+
+ # -----------------------------------------------------------------------------------------------
+ # begin restful actions
+
+
+ # begin UI actions
+ def new
+ invoke_callback(:before_new)
+ @resourceful_item ||= resourceful_model.new
+ send(self.class.authorize_method, :new, @resourceful_item, request)
+ init_instance_variables
+
+ invoke_callback(:respond_to_new)
+ unless performed?
+ respond_to do |format|
+ format.html { render :new }
+ format.xml { render :xml => { :message => "Format not supported! Use the html format." } }
+ format.json { render :json => { :message => "Format not supported! Use the html format." } }
+ end
+ end
+
+ invoke_callback(:after_new)
+ end
+
+ def edit
+ invoke_callback(:before_edit)
+ @resourceful_item ||= resourceful_model.find(params[:id])
+ send(self.class.authorize_method, :edit, @resourceful_item, request)
+ init_instance_variables
+ invoke_callback(:respond_to_edit)
+
+ unless performed?
+ respond_to do |format|
+ format.html { render :edit }
+ format.xml { render :xml => { :message => "Format not supported! Use the html format." } }
+ format.json { render :json => { :message => "Format not supported! Use the html format." } }
+ end
+ end
+
+ invoke_callback(:after_edit)
+ end
+ # end UI actions
+
+
+ # begin READ actions
+ def index
+ invoke_callback(:before_index)
+ @resourceful_list ||= resourceful_model.all
+ send(self.class.authorize_method, :index, @resourceful_list, request)
+ init_instance_variables
+
+ invoke_callback(:respond_to_index)
+ unless performed?
+ respond_to do |format|
+ format.html { render :index }
+ format.xml { render :xml => @resourceful_list }
+ format.json { render :json => @resourceful_list }
+ end
+ end
+
+ invoke_callback(:after_index)
+ end
+
+ def show
+ invoke_callback(:before_show)
+ @resourceful_item ||= resourceful_model.find(params[:id])
+ send(self.class.authorize_method, :show, @resourceful_item, request)
+ init_instance_variables
+
+ invoke_callback(:respond_to_show)
+ unless performed?
+ respond_to do |format|
+ format.html { render :show }
+ format.xml { render :xml => @resourceful_item }
+ format.json { render :json => @resourceful_item }
+ end
+ end
+
+ invoke_callback(:after_show)
+ end
+ # end READ actions
+
+
+ # begin MUTATING actions
+ def create
+ invoke_callback(:before_create)
+ @resourceful_item ||= resourceful_model.new(params[resourceful_model.name.underscore])
+ send(self.class.authorize_method, :create, @resourceful_item, request)
+ init_instance_variables
+ success = @skip_db_create || @resourceful_item.save
+
+ invoke_callback(:respond_to_create)
+ unless performed?
+ respond_to do |format|
+ if success
+ flash[:notice] = "#{resourceful_model.name} was successfully created."
+ format.html { redirect_to(@resourceful_item) }
+ format.xml { render :xml => @resourceful_item, :status => :created, :location => @resourceful_item }
+ format.json { render :json => @resourceful_item, :status => :created, :location => @resourceful_item }
+ else
+ format.html { render :action => "new" }
+ format.xml { render :xml => @resourceful_item.errors, :status => :unprocessable_entity }
+ format.json { render :json => @resourceful_item.errors, :status => :unprocessable_entity }
+ end
+ end
+ end
+
+ invoke_callback(:after_create)
+ end
+
+ def update
+ invoke_callback(:before_update)
+ @resourceful_item ||= resourceful_model.find(params[:id])
+ send(self.class.authorize_method, :update, @resourceful_item, request)
+ init_instance_variables
+ success = @skip_db_update || @resourceful_item.update_attributes(params[resourceful_model.name.underscore])
+
+ invoke_callback(:respond_to_update)
+ unless performed?
+ respond_to do |format|
+ if success
+ flash[:notice] = "#{resourceful_model.name} was successfully updated."
+ format.html { redirect_to(@resourceful_item) }
+ format.xml { render :xml => @resourceful_item }
+ format.json { render :json => @resourceful_item }
+ else
+ format.html { render :action => "edit" }
+ format.xml { render :xml => @resourceful_item.errors, :status => :unprocessable_entity }
+ format.json { render :json => @resourceful_item.errors, :status => :unprocessable_entity }
+ end
+ end
+ end
+
+ invoke_callback(:after_update)
+ end
+
+ def destroy
+ invoke_callback(:before_destroy)
+ @resourceful_item ||= resourceful_model.find(params[:id])
+ send(self.class.authorize_method, :destroy, @resourceful_item, request)
+ init_instance_variables
+ @resourceful_item.destroy unless @skip_db_destroy
+
+ invoke_callback(:respond_to_destroy)
+ unless performed?
+ flash[:notice] = "#{resourceful_model.name} was successfully destroyed" if @resourceful_item.destroyed?
+ respond_to do |format|
+ format.html { redirect_to root_url }
+ format.xml { render :xml => @resourceful_item }
+ format.json { render :json => @resourceful_item }
+ end
+ end
+
+ invoke_callback(:after_destroy)
+ end
+ # end MUTATING actions
+
+ # end restful actions
+ # -----------------------------------------------------------------------------------------------
+
+ protected
+
+ def resourceful_model
+ self.class.resourceful_model
+ end
+
+ def init_instance_variables
+ instance_variable_set(item_instance_var_name, @resourceful_item) unless instance_variable_get(item_instance_var_name)
+ instance_variable_set(list_instance_var_name, @resourceful_list) unless instance_variable_get(list_instance_var_name)
+ end
+
+ def item_instance_var_name
+ return @item_instance_var_name if @item_instance_var_name
+ name = resourceful_model.name.underscore
+ @item_instance_var_name ||= "@#{name[(name.rindex(/\//) + 1)..-1]}"
+ end
+
+ def list_instance_var_name
+ @list_instance_var_name ||= item_instance_var_name.pluralize
+ end
+
+ private
+
+ def invoke_callback(name)
+ send(name) if respond_to?(name)
+ end
+
+end
245 spec/coast_spec.rb
@@ -0,0 +1,245 @@
+require "rubygems"
+
+# fix MiniTest issue with 1.9
+unless defined? Gem::Deprecate
+ module Gem
+ Deprecate = Module.new do
+ include Deprecate
+ end
+ end
+end
+
+require "bundler/setup"
+require "minitest/autorun"
+require "active_support/all"
+require "turn"
+require "pry"
+
+dirname = File.dirname(__FILE__)
+require File.join(dirname, "mock")
+Dir.glob(File.join(dirname, "..", "lib", "*.rb")) { |f| require f }
+MODEL_PATH = File.join(dirname, "testable_model.rb")
+CONTROLLER_PATH = File.join(dirname, "testable_controller.rb")
+load MODEL_PATH
+load CONTROLLER_PATH
+
+
+
+
+describe Coast do
+ RESTFUL_METHODS = %w(index show new edit create update destroy)
+ ITEM_METHODS = %w(show new edit create update destroy)
+ UI_METHODS = %w(new edit)
+ READ_METHODS = %w(index show)
+ MUTATE_METHODS = %w(create update destroy)
+
+ # Resets the mock class defs.
+ def reset
+ Coast.send(:remove_const, :TestableController)
+ Coast.send(:remove_const, :TestableModel)
+ load MODEL_PATH
+ load CONTROLLER_PATH
+ Coast::TestableController.set_resourceful_model Coast::TestableModel
+ end
+
+ # class method tests ============================================================================
+
+ describe :set_authorize_method do
+ it "sets the method used to perform authorization checks" do
+ reset
+ Coast::TestableController.set_authorize_method :authorize!
+ assert Coast::TestableController.authorize_method == :authorize!
+ end
+ end
+
+ describe :authorize_method= do
+ it "sets the method used to perform authorization checks" do
+ reset
+ Coast::TestableController.authorize_method = :authorize!
+ assert Coast::TestableController.authorize_method == :authorize!
+ end
+ end
+
+ describe :set_resourceful_model do
+ it "sets the model that the controller manages" do
+ reset
+ model = Object.new
+ Coast::TestableController.set_resourceful_model model
+ assert Coast::TestableController.resourceful_model == model
+ end
+ end
+
+ describe :resourceful_model= do
+ it "sets the model that the controller manages" do
+ reset
+ model = Object.new
+ Coast::TestableController.resourceful_model = model
+ assert Coast::TestableController.resourceful_model == model
+ end
+ end
+
+ def verify_callback_setter(event, action)
+ reset
+ Coast::TestableController.send(event, action, &(lambda {}))
+ assert Coast::TestableController.new.respond_to?("#{event}_#{action}")
+ end
+
+ describe :before do
+ it "stores a before callback for all RESTful actions" do
+ RESTFUL_METHODS.each { |m| verify_callback_setter :before, m }
+ end
+ end
+
+ describe :respond_to do
+ it "stores a respond_to callback for all RESTful actions" do
+ RESTFUL_METHODS.each { |m| verify_callback_setter :respond_to, m }
+ end
+ end
+
+ describe :after do
+ it "stores an after callback for all RESTful actions" do
+ RESTFUL_METHODS.each { |m| verify_callback_setter :after, m }
+ end
+ end
+
+
+
+
+
+
+
+
+
+
+
+ # instance method tests =========================================================================
+
+
+ it "supports all RESTful methods" do
+ reset
+ controller = Coast::TestableController.new
+ RESTFUL_METHODS.each { |m| controller.must_respond_to m }
+ end
+
+ # %w(index show new).each do |method|
+ RESTFUL_METHODS.each do |method|
+ describe "##{method}" do
+ before do
+ reset
+ end
+
+ it "responds and renders" do
+ controller = Coast::TestableController.new
+ controller.send(method)
+ assert controller.responded?, "Did not respond"
+ assert controller.rendered?, "Did not render"
+ end
+
+ it "renders for the formats html, xml, json" do
+ controller = Coast::TestableController.new
+ controller.send(method)
+ assert controller.html, "Did not respond to the html format"
+ assert controller.xml, "Did not respond to the xml format"
+ assert controller.json, "Did not respond to the json format"
+ end
+
+ it "invokes the callbacks before, respond_to, after" do
+ callbacks = []
+
+ Coast::TestableController.class_eval do
+ before(method) { callbacks << :before }
+ respond_to(method) { callbacks << :respond_to }
+ after(method) { callbacks << :after }
+ end
+
+ controller = Coast::TestableController.new
+ controller.send(method)
+ assert callbacks.include?(:before), "Did not invoke the before callback"
+ assert callbacks.include?(:respond_to), "Did not invoke the respond_to callback"
+ assert callbacks.include?(:after), "Did not invoke the after callback"
+ end
+
+ it "allows :respond_to callback to perform the render" do
+ Coast::TestableController.respond_to(method) { @performed = true }
+ controller = Coast::TestableController.new
+ controller.send(method)
+ assert controller.responded? == false, "Did not allow :respond_to callback to perform the render"
+ end
+
+ it "invokes the authorize_method when set" do
+ Coast::TestableController.authorize_method = :authorize!
+ controller = Coast::TestableController.new
+ controller.send(:index)
+ assert controller.authorize_invoked?, "Did not invoke the authorize_method"
+ end
+
+ end
+ end
+
+ ITEM_METHODS.each do |method|
+ describe "##{method}" do
+ before do
+ reset
+ end
+
+ it "allows :before callback to set the item" do
+ item = Object.new
+ Coast::TestableController.before(:index) { @resourceful_item = item }
+ controller = Coast::TestableController.new
+ controller.index
+ assert controller.instance_eval { @resourceful_item } == item, "Did not allow :before callback to set the resourceful_item"
+ end
+
+ it "sets a custom named instance variable for the item" do
+ item = Object.new
+ Coast::TestableController.before(:index) { @resourceful_item = item }
+ controller = Coast::TestableController.new
+ controller.index
+ variable = controller.instance_eval { instance_variable_get(item_instance_var_name) }
+ assert variable != nil, "Did not set a custom instance variable for the item"
+ assert variable == item, "Did not set a custom instance variable for the item to the correct value"
+ end
+
+ end
+ end
+
+ MUTATE_METHODS.each do |method|
+ describe "##{method}" do
+ before do
+ reset
+ end
+
+ it "redirects" do
+ controller = Coast::TestableController.new
+ controller.send(method)
+ assert controller.redirected?, "Did not redirect"
+ end
+ end
+ end
+
+
+ describe "#index" do
+ before do
+ reset
+ end
+
+ it "allows :before callback to set the list" do
+ list = [ Object.new ]
+ Coast::TestableController.before(:index) { @resourceful_list = list }
+ controller = Coast::TestableController.new
+ controller.index
+ assert controller.instance_eval { @resourceful_list } == list, "Did not allow :before callback to set the resourceful_list"
+ end
+
+ it "sets a custom named instance variable for the item" do
+ list = [ Object.new ]
+ Coast::TestableController.before(:index) { @resourceful_list = list }
+ controller = Coast::TestableController.new
+ controller.index
+ variable = controller.instance_eval { instance_variable_get(list_instance_var_name) }
+ assert variable != nil, "Did not set a custom instance variable for the item"
+ assert variable == list, "Did not set a custom instance variable for the item to the correct value"
+ end
+ end
+
+end
39 spec/mock.rb
@@ -0,0 +1,39 @@
+MiniTest::Mock # force to load so our methods don't get removed
+
+module Coast
+ class Mock < MiniTest::Mock
+
+ def expect_with_block(name, retval, args=[], &block)
+ expect(name, retval, args)
+
+ if block_given?
+ expected_calls = @expected_calls[name].select { |call| call[:args].size == args.size }
+ expected_calls.each do |call|
+ call[:block] = block
+ end
+ end
+
+ self
+ end
+
+ def method_missing(name, *args, &block)
+ expected_calls = @expected_calls[name].select { |call| call[:args].size == args.size }
+
+ return super unless expected_calls
+
+ expected_call = expected_calls.find do |call|
+ call[:args].zip(args).all? { |mod, a| mod === a or mod == a }
+ end
+
+ return super unless expected_call
+
+ if expected_call[:block]
+ expected_call[:block].call(*expected_call[:args], &block)
+ return expected_call[:retval]
+ end
+
+ super
+ end
+
+ end
+end
53 spec/testable_controller.rb
@@ -0,0 +1,53 @@
+require "forwardable"
+
+module Coast
+ class TestableController
+ include Coast
+ attr_accessor :html, :xml, :json
+
+ extend Forwardable
+ def_delegator :@mock, :request
+ def_delegator :@mock, :params
+ def_delegator :@mock, :flash
+ def_delegator :@mock, :root_url
+
+ def initialize
+ @mock = Coast::Mock.new
+ @mock.expect(:request, Coast::Mock.new)
+ @mock.expect(:params, {})
+ @mock.expect(:flash, {})
+ @mock.expect(:root_url, "/")
+
+ @format = Coast::Mock.new
+ @format.expect_with_block(:html, nil) { |&b| @html = true; b.call if b }
+ @format.expect_with_block(:xml, nil) { |&b| @xml = true; b.call if b }
+ @format.expect_with_block(:json, nil) { |&b| @json = true; b.call if b }
+ end
+
+ define_method(:responded?) { !!@responded }
+ define_method(:rendered?) { !!@rendered }
+ define_method(:redirected?) { !!@redirected }
+ define_method(:performed?) { !!@performed }
+ define_method(:authorize_invoked?) { !!@authorize_invoked }
+
+ def authorize!(*args)
+ @authorize_invoked = true
+ end
+
+ def respond_to
+ @responded = true
+ yield @format
+ end
+
+ def render(*args)
+ @performed = true
+ @rendered = true
+ end
+
+ def redirect_to(*args)
+ render
+ @redirected = true
+ end
+
+ end
+end
39 spec/testable_model.rb
@@ -0,0 +1,39 @@
+require "forwardable"
+
+module Coast
+ class TestableModel
+ extend Forwardable
+
+ def initialize(*args)
+ end
+
+ def self.table_name
+ "testable_models"
+ end
+
+ def self.find(id)
+ TestableModel.new
+ end
+
+ def self.all
+ [TestableModel.new, TestableModel.new, TestableModel.new]
+ end
+
+ define_method(:saved?) { !!@saved }
+ define_method(:destroyed?) { !!@destroyed }
+ define_method(:attributes_updated?) { !!@attributes_updated }
+
+ def destroy
+ @destroyed = true
+ end
+
+ def update_attributes(*args)
+ @attributes_updated = true
+ end
+
+ def save
+ @saved = true
+ end
+
+ end
+end
Please sign in to comment.
Something went wrong with that request. Please try again.