cathode 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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