cathode 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -125
  3. data/Rakefile +1 -0
  4. data/app/controllers/cathode/base_controller.rb +28 -45
  5. data/app/models/cathode/token.rb +21 -0
  6. data/config/routes.rb +16 -0
  7. data/db/migrate/20140425164100_create_cathode_tokens.rb +11 -0
  8. data/lib/cathode.rb +0 -13
  9. data/lib/cathode/_version.rb +2 -1
  10. data/lib/cathode/action.rb +197 -39
  11. data/lib/cathode/action_dsl.rb +60 -0
  12. data/lib/cathode/base.rb +81 -12
  13. data/lib/cathode/create_request.rb +21 -0
  14. data/lib/cathode/custom_request.rb +5 -0
  15. data/lib/cathode/debug.rb +25 -0
  16. data/lib/cathode/destroy_request.rb +13 -0
  17. data/lib/cathode/engine.rb +9 -0
  18. data/lib/cathode/exceptions.rb +20 -0
  19. data/lib/cathode/index_request.rb +40 -0
  20. data/lib/cathode/object_collection.rb +49 -0
  21. data/lib/cathode/query.rb +24 -0
  22. data/lib/cathode/railtie.rb +21 -0
  23. data/lib/cathode/request.rb +139 -7
  24. data/lib/cathode/resource.rb +50 -19
  25. data/lib/cathode/resource_dsl.rb +46 -0
  26. data/lib/cathode/show_request.rb +13 -0
  27. data/lib/cathode/update_request.rb +26 -0
  28. data/lib/cathode/version.rb +112 -23
  29. data/lib/tasks/cathode_tasks.rake +5 -4
  30. data/spec/dummy/app/api/api.rb +0 -0
  31. data/spec/dummy/app/models/payment.rb +3 -0
  32. data/spec/dummy/app/models/product.rb +1 -0
  33. data/spec/dummy/app/models/sale.rb +5 -0
  34. data/spec/dummy/app/models/salesperson.rb +3 -0
  35. data/spec/dummy/db/development.sqlite3 +0 -0
  36. data/spec/dummy/db/migrate/20140409183635_create_sales.rb +11 -0
  37. data/spec/dummy/db/migrate/20140423172419_create_salespeople.rb +11 -0
  38. data/spec/dummy/db/migrate/20140424181343_create_payments.rb +10 -0
  39. data/spec/dummy/db/schema.rb +31 -1
  40. data/spec/dummy/db/test.sqlite3 +0 -0
  41. data/spec/dummy/log/development.log +1167 -0
  42. data/spec/dummy/log/test.log +180602 -0
  43. data/spec/dummy/spec/factories/payments.rb +8 -0
  44. data/spec/dummy/spec/factories/products.rb +1 -1
  45. data/spec/dummy/spec/factories/sales.rb +9 -0
  46. data/spec/dummy/spec/factories/salespeople.rb +7 -0
  47. data/spec/dummy/spec/requests/requests_spec.rb +434 -0
  48. data/spec/lib/cathode/action_spec.rb +136 -0
  49. data/spec/lib/cathode/base_spec.rb +34 -0
  50. data/spec/lib/cathode/create_request_spec.rb +40 -0
  51. data/spec/lib/cathode/custom_request_spec.rb +31 -0
  52. data/spec/lib/cathode/debug_spec.rb +25 -0
  53. data/spec/lib/cathode/destroy_request_spec.rb +28 -0
  54. data/spec/lib/cathode/index_request_spec.rb +62 -0
  55. data/spec/lib/cathode/object_collection_spec.rb +66 -0
  56. data/spec/lib/cathode/query_spec.rb +28 -0
  57. data/spec/lib/cathode/request_spec.rb +58 -0
  58. data/spec/lib/cathode/resource_spec.rb +482 -0
  59. data/spec/lib/cathode/show_request_spec.rb +23 -0
  60. data/spec/lib/cathode/update_request_spec.rb +41 -0
  61. data/spec/lib/cathode/version_spec.rb +416 -0
  62. data/spec/models/cathode/token_spec.rb +62 -0
  63. data/spec/spec_helper.rb +8 -2
  64. data/spec/support/factories/payments.rb +3 -0
  65. data/spec/support/factories/sale.rb +3 -0
  66. data/spec/support/factories/salespeople.rb +3 -0
  67. data/spec/support/factories/token.rb +3 -0
  68. data/spec/support/helpers.rb +13 -2
  69. metadata +192 -47
  70. data/app/helpers/cathode/application_helper.rb +0 -4
  71. data/spec/dummy/app/api/dummy_api.rb +0 -4
  72. data/spec/integration/api_spec.rb +0 -88
  73. data/spec/lib/action_spec.rb +0 -140
  74. data/spec/lib/base_spec.rb +0 -28
  75. data/spec/lib/request_spec.rb +0 -5
  76. data/spec/lib/resources_spec.rb +0 -78
  77. data/spec/lib/versioning_spec.rb +0 -104
@@ -1,27 +1,96 @@
1
- require 'pry'
2
- require 'cathode/request'
3
- require 'cathode/resource'
4
- require 'cathode/action'
5
- require 'cathode/version'
1
+ require 'cathode/engine'
2
+ require 'cathode/railtie'
3
+ require 'cathode/exceptions'
6
4
 
5
+ # Cathode is a gem for creating API boilerplate for resourceful Rails
6
+ # applications. It has first-class support for versions, model-backed resources,
7
+ # default actions like `create` and `destroy`, and custom actions.
7
8
  module Cathode
8
- class Base
9
- @@versions = {}
9
+ autoload :Action, 'cathode/action'
10
+ autoload :ActionDsl, 'cathode/action_dsl'
11
+ autoload :CreateRequest, 'cathode/create_request'
12
+ autoload :CustomRequest, 'cathode/custom_request'
13
+ autoload :Debug, 'cathode/debug'
14
+ autoload :DeepClone, 'deep_clone'
15
+ autoload :DestroyRequest, 'cathode/destroy_request'
16
+ autoload :IndexRequest, 'cathode/index_request'
17
+ autoload :ObjectCollection, 'cathode/object_collection'
18
+ autoload :Query, 'cathode/query'
19
+ autoload :Request, 'cathode/request'
20
+ autoload :Resource, 'cathode/resource'
21
+ autoload :ResourceDsl, 'cathode/resource_dsl'
22
+ autoload :Semantic, 'semantic'
23
+ autoload :ShowRequest, 'cathode/show_request'
24
+ autoload :UpdateRequest, 'cathode/update_request'
25
+ autoload :Version, 'cathode/version'
26
+
27
+ # The actions whose default behavior is defined by Cathode.
28
+ DEFAULT_ACTIONS = [:index, :show, :create, :update, :destroy]
10
29
 
30
+ # Holds the top-level Cathode accessors for defining an API.
31
+ class Base
11
32
  class << self
33
+ attr_reader :tokens_required
34
+
35
+ # Defines an API
36
+ # @param block The API's versions and resources, defined using this
37
+ # class's `version`, `resources`, and `resource` methods
38
+ def define(&block)
39
+ instance_eval(&block)
40
+ end
41
+
42
+ # Lists the collection of versions associated with this API
43
+ # @return [Cathode::ObjectCollection] the collection of versions
44
+ def versions
45
+ Version.all
46
+ end
47
+
48
+ # Defines a new version
49
+ # @param version_number [String, Fixnum, Float] A number or string
50
+ # representing a SemVer-compliant version number. If a `Fixnum` or
51
+ # `Float` is passed, it will be converted to a string before being
52
+ # evaluated for SemVer compliance, so passing `1.5` is equivalent to
53
+ # passing `'1.5.0'`.
54
+ # @param block A block defining the version's resources and actions, and
55
+ # has access to the methods in the {Cathode::ActionDsl} and {Cathode::ResourceDsl}
56
+ def version(version_number, &block)
57
+ Version.define(version_number, &block)
58
+ end
59
+
60
+ # Defines a singular resource on version 1.0.0 of the API
61
+ # @param resource_name [Symbol] The resource's name
62
+ # @param params [Hash] Optional params, e.g. `{ actions: :all }`
63
+ # @param block A block defining the resource's actions and properties, run
64
+ # inside the context of version 1.0.0 with access to the methods in the
65
+ # {Cathode::ActionDsl} and {Cathode::ResourceDsl}
12
66
  def resource(resource_name, params = nil, &block)
13
67
  version 1 do
14
68
  resource resource_name, params, &block
15
69
  end
16
70
  end
17
71
 
18
- def versions
19
- @@versions
72
+ # Defines a plural resource on version 1.0.0 of the API
73
+ # @param resource_name [Symbol] The resource's name
74
+ # @param params [Hash] Optional params, e.g. `{ actions: :all }`
75
+ # @param block A block defining the resource's actions and properties, run
76
+ # inside the context of version 1.0.0 with access to the methods in the
77
+ # {Cathode::ActionDsl} and {Cathode::ResourceDsl}
78
+ def resources(resource_name, params = nil, &block)
79
+ version 1 do
80
+ resources resource_name, params, &block
81
+ end
20
82
  end
21
83
 
22
- def version(version_number, &block)
23
- version = Version.new(version_number, &block)
24
- versions[version.version.to_s] = version
84
+ # Configures this API to require incoming requests to have a valid token
85
+ def require_tokens
86
+ @tokens_required = true
87
+ end
88
+
89
+ private
90
+
91
+ def reset!
92
+ versions.clear
93
+ @tokens_required = false
25
94
  end
26
95
  end
27
96
  end
@@ -0,0 +1,21 @@
1
+ module Cathode
2
+ # Defines the default behavior for a create request.
3
+ class CreateRequest < Request
4
+ # Sets the default action to create a new resource. If the resource is
5
+ # singular, sets the parent resource `id` as well.
6
+ def default_action_block
7
+ proc do
8
+ begin
9
+ create_params = instance_eval(&@strong_params)
10
+ if resource.singular
11
+ create_params["#{parent_resource_name}_id"] = parent_resource_id
12
+ end
13
+ body model.create(create_params)
14
+ rescue ActionController::ParameterMissing => error
15
+ body error.message
16
+ status :bad_request
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,5 @@
1
+ module Cathode
2
+ # Holds a custom (non-`[index, show, create, update, delete]`) request. Custom
3
+ # requests have no default actions, so this is a no-op.
4
+ class CustomRequest < Request; end
5
+ end
@@ -0,0 +1,25 @@
1
+ module Cathode
2
+ # Provides information about the Cathode API.
3
+ class Debug
4
+ class << self
5
+ # Gathers information about the API's versions, properties, resources, and
6
+ # actions.
7
+ # @return [String] A string listing the versions, resources, and actions
8
+ def info
9
+ output = ''
10
+ Cathode::Base.versions.each do |version|
11
+ output += "\nVersion #{version.version}"
12
+
13
+ version._resources.each do |resource|
14
+ output += "\n #{resource.name}/"
15
+ resource.actions.each do |action|
16
+ output += "\n #{action.name}"
17
+ end
18
+ end
19
+ end
20
+
21
+ output
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ module Cathode
2
+ # Defines the default behavior for a destroy request.
3
+ class DestroyRequest < Request
4
+ # Sets the default action to destroy a resource. If the resource is
5
+ # singular, destroys the parent's associated resource. Otherwise, destroys
6
+ # the resource directly.
7
+ def default_action_block
8
+ proc do
9
+ record.destroy
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,5 +1,14 @@
1
+ require 'rails'
1
2
  module Cathode
3
+ # Define a Rails engine with an isolated `Cathode` namespace.
2
4
  class Engine < ::Rails::Engine
5
+ config.generators do |g|
6
+ g.test_framework :rspec, fixture: false
7
+ g.fixture_replacement :factory_girl, dir: 'spec/factories'
8
+ g.assets false
9
+ g.helper false
10
+ end
11
+
3
12
  isolate_namespace Cathode
4
13
  end
5
14
  end
@@ -1,3 +1,23 @@
1
1
  module Cathode
2
+ # Raised when a resource is initialized but there is no constant (ActiveRecord
3
+ # model) that matches it.
2
4
  class UnknownResourceError < NameError; end
5
+
6
+ # Raised when a nonexistent action is referred to.
7
+ class UnknownActionError < NameError; end
8
+
9
+ # Raised when an `attributes` block is not passed in a context that requires
10
+ # one.
11
+ class UnknownAttributesError < NameError; end
12
+
13
+ # Raised when a custom action is defined without an HTTP method.
14
+ class RequestMethodMissingError < NameError; end
15
+
16
+ # Raised when an ActiveModel association is not present in a context that
17
+ # requires one.
18
+ class MissingAssociationError < NameError; end
19
+
20
+ # Raised when an action's behavior has not been defined in a context that
21
+ # requires it.
22
+ class ActionBehaviorMissingError < NameError; end
3
23
  end
@@ -0,0 +1,40 @@
1
+ module Cathode
2
+ # Defines the default behavior for an index request.
3
+ class IndexRequest < Request
4
+ # Determine the model to use depending on the request. If a sub-resource
5
+ # was requested, use the parent model to get the association. Otherwise, use
6
+ # all the resource's model's records.
7
+ def model
8
+ if @resource_tree.size > 1
9
+ parent_model_id = params["#{@resource_tree.first.name.to_s.singularize}_id"]
10
+ model = @resource_tree.first.model.find(parent_model_id)
11
+ @resource_tree.drop(1).each do |resource|
12
+ model = model.send resource.name
13
+ end
14
+ model
15
+ else
16
+ super.all
17
+ end
18
+ end
19
+
20
+ # Determine the default action to use depending on the request. If the
21
+ # `page` param was passed and the action allows paging, page the results.
22
+ # Otherwise, set the request body to all records.
23
+ def default_action_block
24
+ proc do
25
+ all_records = model
26
+
27
+ if allowed?(:paging) && params[:page]
28
+ page = params[:page]
29
+ per_page = params[:per_page] || 10
30
+ lower_bound = (per_page - 1) * page
31
+ upper_bound = lower_bound + per_page - 1
32
+
33
+ body all_records[lower_bound..upper_bound]
34
+ else
35
+ body all_records
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,49 @@
1
+ module Cathode
2
+ # Provides an enumerable interface for arrays of objects.
3
+ class ObjectCollection
4
+ attr_accessor :objects
5
+
6
+ delegate :each, to: :objects
7
+ delegate :select, to: :objects
8
+
9
+ def initialize
10
+ @objects = []
11
+ end
12
+
13
+ # Look up an object by its `name` property.
14
+ # @param name [Symbol] The object's name
15
+ # @return [Object]
16
+ def find(name)
17
+ objects.detect { |o| o.name == name }
18
+ end
19
+
20
+ # An array of all the `name` properties in this object.
21
+ # @return [Array]
22
+ def names
23
+ objects.map(&:name)
24
+ end
25
+
26
+ # Adds a new object to the collection.
27
+ # @param items [Object, Array] A single object or an array of objects to add
28
+ # @return [ObjectCollection] self
29
+ def add(items)
30
+ items = [items] unless items.is_a?(Array)
31
+ self.objects += items
32
+ self
33
+ end
34
+
35
+ # Delets an object from the collection by name.
36
+ # @param name [Symbol] The name of the object to remove
37
+ # @return [ObjectCollection] self
38
+ def delete(name)
39
+ objects.delete find(name)
40
+ self
41
+ end
42
+
43
+ # Forwards all missing methods to the `objects` array stored in this
44
+ # collection.
45
+ def method_missing(method, args)
46
+ objects.send method, args
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,24 @@
1
+ module Cathode
2
+ # Holds the Cathode Query DSL interface.
3
+ class Query
4
+ attr_reader :results
5
+
6
+ # Initialize and parse a query.
7
+ # @param model [Class] A subclass of `ActiveRecord::Base`.
8
+ # @param query [String] The query to be executed
9
+ def initialize(model, query)
10
+ clauses = query.split ','
11
+ results = model
12
+ clauses.each do |clause|
13
+ words = clause.split
14
+ results = case words.first
15
+ when 'where'
16
+ results.where(words.drop(1).join ' ')
17
+ else
18
+ results.where(words.join ' ')
19
+ end
20
+ end
21
+ @results = results
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ require 'rack/cors'
2
+
3
+ module Cathode
4
+ # Defines a Railtie to hook into the Rails initialization process. Autoloads
5
+ # API code from the `app/api` directory and adds `Rack::Cors` to the
6
+ # application's middleware stack.
7
+ class Railtie < Rails::Railtie
8
+ initializer 'cathode.add_api_to_autoload_paths' do |app|
9
+ Dir[File.join(Rails.root, 'app', 'api', '**', '*.rb')].each { |f| require f }
10
+ end
11
+
12
+ initializer 'cathode.enable_cors' do |app|
13
+ app.config.middleware.use Rack::Cors do
14
+ allow do
15
+ origins '*'
16
+ resource '*', headers: :any, methods: [:get, :post, :put, :delete, :options]
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -1,15 +1,147 @@
1
1
  module Cathode
2
+ # A `Request` object is created when a Rails request is intercepted by
3
+ # Cathode. This object is responsible for enforcing version headers as well as
4
+ # token authorization headers. After header enforcement, the object figures
5
+ # out which version and resource/action to use to process the request.
6
+ # Finally, the request's `_body` (HTTP response body) and `_status` (HTTP
7
+ # status code) are set and rendered from the controller that initiated the
8
+ # request.
2
9
  class Request
3
- attr_reader :body,
4
- :status
10
+ attr_reader :_body,
11
+ :_status,
12
+ :action,
13
+ :context,
14
+ :custom_logic,
15
+ :model,
16
+ :resource
5
17
 
6
- def initialize(http_request, params)
7
- version = http_request.headers['HTTP_ACCEPT_VERSION']
18
+ delegate :allowed?, to: :action
19
+ delegate :params, to: :context
8
20
 
9
- resource = params['controller'].camelize.demodulize.downcase.to_sym
10
- response = Version.perform_request_with_version(version, resource, params)
21
+ class << self
22
+ # Creates a request by initializing the appropriate subclass.
23
+ # @param context [ActionController] The controller responding to the
24
+ # request
25
+ # @return [IndexRequest, ShowRequest, CreateRequest, UpdateRequest,
26
+ # DestroyRequest, CustomRequest] The subclassed request
27
+ def create(context)
28
+ klass = case context.params[:action].to_sym
29
+ when :index
30
+ IndexRequest
31
+ when :show
32
+ ShowRequest
33
+ when :create
34
+ CreateRequest
35
+ when :update
36
+ UpdateRequest
37
+ when :destroy
38
+ DestroyRequest
39
+ else
40
+ CustomRequest
41
+ end
42
+ klass.new(context)
43
+ end
44
+ end
45
+
46
+ # Initializes the request
47
+ # @param context [ActionController] The controller responding to the request
48
+ def initialize(context)
49
+ @context = context
50
+
51
+ version_number = context.request.headers['HTTP_ACCEPT_VERSION']
52
+
53
+ if version_number.nil?
54
+ @_status, @_body = :bad_request, 'A version number must be passed in the Accept-Version header'
55
+ return self
56
+ end
57
+
58
+ version = Version.find(version_number)
59
+ unless version.present?
60
+ @_status, @_body = :bad_request, "Unknown API version: #{version_number}"
61
+ return self
62
+ end
63
+
64
+ action_name = params[:action]
65
+ if action_name == 'custom'
66
+ action_name = context.request.path.split('/').last
67
+ end
68
+
69
+ params[:controller].slice! 'cathode/'
70
+ resources = params[:controller].split('_').map(&:to_sym)
71
+ resource = version._resources.find(resources.first)
72
+ @resource_tree = [resource]
73
+ subresources = resources.drop(1).collect do |r|
74
+ resource = resource._resources.find(r)
75
+ end
76
+ @resource_tree += subresources
77
+ resource = @resource_tree.last
78
+
79
+ @resource = resource
80
+
81
+ if @resource_tree.size > 1
82
+ @action = resource.actions.find(action_name.to_sym)
83
+ else
84
+ unless version.action?(resource.try(:name) || '', action_name)
85
+ @_status = :not_found
86
+ return self
87
+ end
88
+ @action = version._resources.find(resource.name).actions.find(action_name.to_sym)
89
+ end
90
+
91
+ @strong_params = @action.strong_params
92
+ @_status = :ok
93
+
94
+ if action.override_block
95
+ context.instance_eval(&action.override_block)
96
+ else
97
+ action_block = action.action_block
98
+ if action_block.nil? && respond_to?(:default_action_block)
99
+ action_block = default_action_block
100
+ end
101
+
102
+ instance_eval(&action_block)
103
+ end
104
+
105
+ body if @_body.nil?
106
+ end
107
+
108
+ private
109
+
110
+ def attributes(&block)
111
+ block.call(params)
112
+ rescue ActionController::ParameterMissing => error
113
+ body error.message
114
+ status :bad_request
115
+ end
116
+
117
+ def model
118
+ resource.model
119
+ end
120
+
121
+ def parent_resource_id
122
+ params["#{parent_resource_name}_id"]
123
+ end
124
+
125
+ def parent_resource_name
126
+ resource.parent.name.to_s.singularize
127
+ end
128
+
129
+ def record
130
+ if resource.singular
131
+ parent_model = resource.parent.model.find(parent_resource_id)
132
+ parent_model.send resource.name
133
+ else
134
+ model.find params[:id]
135
+ end
136
+ end
137
+
138
+ def body(value = Hash.new, &block)
139
+ return if _body.present?
140
+ @_body = block_given? ? block.call : value
141
+ end
11
142
 
12
- @status, @body = response[:status], response[:body]
143
+ def status(value = nil, &block)
144
+ @_status = block_given? ? block.call : value
13
145
  end
14
146
  end
15
147
  end