bradphelan-sinatras-hat 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (91) hide show
  1. data/.gitignore +4 -0
  2. data/LICENSE +22 -0
  3. data/README.md +235 -0
  4. data/Rakefile +59 -0
  5. data/VERSION +1 -0
  6. data/bradphelan-sinatras-hat.gemspec +145 -0
  7. data/ci.rb +9 -0
  8. data/example/app-with-auth.rb +14 -0
  9. data/example/app-with-cache.rb +30 -0
  10. data/example/app.rb +23 -0
  11. data/example/lib/comment.rb +13 -0
  12. data/example/lib/common.rb +19 -0
  13. data/example/lib/post.rb +16 -0
  14. data/example/simple-app.rb +4 -0
  15. data/example/views/comments/index.erb +6 -0
  16. data/example/views/comments/show.erb +1 -0
  17. data/example/views/posts/index.erb +8 -0
  18. data/example/views/posts/new.erb +11 -0
  19. data/example/views/posts/show.erb +28 -0
  20. data/features/authenticated.feature +12 -0
  21. data/features/create.feature +16 -0
  22. data/features/destroy.feature +18 -0
  23. data/features/edit.feature +17 -0
  24. data/features/formats.feature +19 -0
  25. data/features/headers.feature +28 -0
  26. data/features/index.feature +23 -0
  27. data/features/layouts.feature +11 -0
  28. data/features/nested.feature +20 -0
  29. data/features/new.feature +20 -0
  30. data/features/only.feature +13 -0
  31. data/features/show.feature +31 -0
  32. data/features/steps/authenticated_steps.rb +10 -0
  33. data/features/steps/common_steps.rb +77 -0
  34. data/features/steps/create_steps.rb +21 -0
  35. data/features/steps/destroy_steps.rb +16 -0
  36. data/features/steps/edit_steps.rb +7 -0
  37. data/features/steps/format_steps.rb +11 -0
  38. data/features/steps/header_steps.rb +7 -0
  39. data/features/steps/index_steps.rb +26 -0
  40. data/features/steps/nested_steps.rb +11 -0
  41. data/features/steps/new_steps.rb +15 -0
  42. data/features/steps/only_steps.rb +10 -0
  43. data/features/steps/show_steps.rb +24 -0
  44. data/features/steps/update_steps.rb +22 -0
  45. data/features/support/env.rb +17 -0
  46. data/features/support/views/comments/index.erb +5 -0
  47. data/features/support/views/layout.erb +9 -0
  48. data/features/support/views/people/edit.erb +1 -0
  49. data/features/support/views/people/index.erb +1 -0
  50. data/features/support/views/people/layout.erb +9 -0
  51. data/features/support/views/people/new.erb +1 -0
  52. data/features/support/views/people/show.erb +1 -0
  53. data/features/update.feature +25 -0
  54. data/lib/core_ext/array.rb +5 -0
  55. data/lib/core_ext/hash.rb +23 -0
  56. data/lib/core_ext/module.rb +14 -0
  57. data/lib/core_ext/object.rb +45 -0
  58. data/lib/sinatras-hat.rb +22 -0
  59. data/lib/sinatras-hat/actions.rb +81 -0
  60. data/lib/sinatras-hat/authentication.rb +55 -0
  61. data/lib/sinatras-hat/extendor.rb +24 -0
  62. data/lib/sinatras-hat/hash_mutator.rb +18 -0
  63. data/lib/sinatras-hat/logger.rb +36 -0
  64. data/lib/sinatras-hat/maker.rb +187 -0
  65. data/lib/sinatras-hat/model.rb +110 -0
  66. data/lib/sinatras-hat/resource.rb +57 -0
  67. data/lib/sinatras-hat/responder.rb +106 -0
  68. data/lib/sinatras-hat/response.rb +60 -0
  69. data/lib/sinatras-hat/router.rb +46 -0
  70. data/sinatras-hat.gemspec +34 -0
  71. data/spec/actions/create_spec.rb +68 -0
  72. data/spec/actions/destroy_spec.rb +58 -0
  73. data/spec/actions/edit_spec.rb +52 -0
  74. data/spec/actions/index_spec.rb +72 -0
  75. data/spec/actions/new_spec.rb +39 -0
  76. data/spec/actions/show_spec.rb +85 -0
  77. data/spec/actions/update_spec.rb +83 -0
  78. data/spec/extendor_spec.rb +78 -0
  79. data/spec/fixtures/views/articles/edit.erb +1 -0
  80. data/spec/fixtures/views/articles/index.erb +1 -0
  81. data/spec/fixtures/views/articles/new.erb +1 -0
  82. data/spec/fixtures/views/articles/show.erb +1 -0
  83. data/spec/hash_mutator_spec.rb +23 -0
  84. data/spec/maker_spec.rb +411 -0
  85. data/spec/model_spec.rb +152 -0
  86. data/spec/resource_spec.rb +74 -0
  87. data/spec/responder_spec.rb +139 -0
  88. data/spec/response_spec.rb +120 -0
  89. data/spec/router_spec.rb +105 -0
  90. data/spec/spec_helper.rb +80 -0
  91. metadata +161 -0
@@ -0,0 +1,22 @@
1
+ $LOAD_PATH << File.join(File.dirname(__FILE__))
2
+
3
+ require 'benchmark'
4
+ require 'sinatra/base'
5
+ require 'extlib'
6
+
7
+ require 'core_ext/array'
8
+ require 'core_ext/hash'
9
+ require 'core_ext/object'
10
+ require 'core_ext/module'
11
+
12
+ require 'sinatras-hat/logger'
13
+ require 'sinatras-hat/extendor'
14
+ require 'sinatras-hat/authentication'
15
+ require 'sinatras-hat/hash_mutator'
16
+ require 'sinatras-hat/resource'
17
+ require 'sinatras-hat/response'
18
+ require 'sinatras-hat/responder'
19
+ require 'sinatras-hat/model'
20
+ require 'sinatras-hat/router'
21
+ require 'sinatras-hat/actions'
22
+ require 'sinatras-hat/maker'
@@ -0,0 +1,81 @@
1
+ module Sinatra
2
+ module Hat
3
+ # Contains all of the actions that Sinatra's Hat supports.
4
+ # Each action states a name, a path, optionally, the HTTP
5
+ # verb, then a block which takes a request object, optionally
6
+ # loads data using the :finder or :record options, then
7
+ # responds, based on whether or not the action was a success
8
+ #
9
+ # NOTE: only the :create action renders a different :failure
10
+ module Actions
11
+ def self.included(map)
12
+ map.action :destroy, '/:id', :verb => :delete do |request|
13
+ record = model.find(request.params) || request.not_found
14
+ record.destroy
15
+ responder.success(:destroy, request, record)
16
+ end
17
+
18
+ map.action :new, '/new' do |request|
19
+ new_record = model.new(request.params)
20
+ responder.success(:new, request, new_record)
21
+ end
22
+
23
+ map.action :update, '/:id', :verb => :put do |request|
24
+ record = model.update(request.params) || request.not_found
25
+ result = record.save ? :success : :failure
26
+ responder.send(result, :update, request, record)
27
+ end
28
+
29
+ map.action :edit, '/:id/edit' do |request|
30
+ record = model.find(request.params) || request.not_found
31
+ responder.success(:edit, request, record)
32
+ end
33
+
34
+ map.action :show, '/:id' do |request|
35
+ record = model.find(request.params) || request.not_found
36
+ set_cache_headers(request, record) unless protected?(:show)
37
+ responder.success(:show, request, record)
38
+ end
39
+
40
+ map.action :create, '/', :verb => :post do |request|
41
+ record = model.new(request.params)
42
+ if record
43
+ puts "RECORD #{record.to_csv}"
44
+ result = record.save ? :success : :failure
45
+ puts "RESULT #{result}"
46
+ responder.send(result, :create, request, record)
47
+ else
48
+ responder.send(:failure, request, request.not_found)
49
+ end
50
+
51
+ end
52
+
53
+ map.action :index, '/' do |request|
54
+ records = model.all(request.params) || request.not_found
55
+ set_cache_headers(request, records) unless protected?(:index)
56
+ responder.success(:index, request, records)
57
+ end
58
+
59
+ private
60
+
61
+ def set_cache_headers(request, data)
62
+
63
+ set_etag(request, data)
64
+ set_last_modified(request, data)
65
+ end
66
+
67
+ def set_etag(request, data)
68
+ record = model.find_last_modified(Array(data))
69
+ return unless record.respond_to?(:updated_at)
70
+ request.etag("#{record.id}-#{record.updated_at}-#{data.is_a?(Array)}")
71
+ end
72
+
73
+ def set_last_modified(request, data)
74
+ record = model.find_last_modified(Array(data))
75
+ return unless record.respond_to?(:updated_at)
76
+ request.last_modified(record.updated_at)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,55 @@
1
+ # from http://www.gittr.com/index.php/archive/sinatra-basic-authentication-selectively-applied
2
+ # adapted by pat nakajima for sinatra's hat
3
+ module Sinatra
4
+ module Authorization
5
+ class ProtectedAction
6
+ attr_reader :credentials, :request, :block
7
+
8
+ def initialize(request, credentials={}, &block)
9
+ @credentials, @request, @block = credentials, request, block
10
+ end
11
+
12
+ def check!
13
+ unauthorized! unless auth.provided?
14
+ bad_request! unless auth.basic?
15
+ unauthorized! unless authorize(*auth.credentials)
16
+ end
17
+
18
+ def remote_user
19
+ auth.username
20
+ end
21
+
22
+ private
23
+
24
+ def authorize(username, password)
25
+ block.call(username, password)
26
+ end
27
+
28
+ def unauthorized!
29
+ request.response.headers['WWW-Authenticate'] = %(Basic realm="#{credentials[:realm]}")
30
+ throw :halt, [ 401, 'Authorization Required' ]
31
+ end
32
+
33
+ def bad_request!
34
+ throw :halt, [ 400, 'Bad Request' ]
35
+ end
36
+
37
+ def auth
38
+ @auth ||= Rack::Auth::Basic::Request.new(request.env)
39
+ end
40
+ end
41
+
42
+ module Helpers
43
+ def protect!(request)
44
+ return if authorized?(request)
45
+ guard = ProtectedAction.new(request, credentials, &authenticator)
46
+ guard.check!
47
+ request.env['REMOTE_USER'] = guard.remote_user
48
+ end
49
+
50
+ def authorized?(request)
51
+ request.env['REMOTE_USER']
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,24 @@
1
+ module Sinatra
2
+ module Hat
3
+ # This module gives both Sinatra::Base and Sinatra::Hat::Maker
4
+ # the #mount method, which is used to mount resources. When
5
+ # mount is called in an instance of Maker, it sets the new
6
+ # instance's parent.
7
+ module Extendor
8
+ def mount(klass, options={}, &block)
9
+ unless kind_of?(Sinatra::Hat::Maker)
10
+ use Rack::MethodOverride
11
+ end
12
+
13
+ Maker.new(klass, options).tap do |maker|
14
+ maker.parent = self if kind_of?(Sinatra::Hat::Maker)
15
+ maker.setup(@app || self)
16
+ maker.instance_eval(&block) if block_given?
17
+ maker.generate_routes!
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ Sinatra::Base.extend(Sinatra::Hat::Extendor)
@@ -0,0 +1,18 @@
1
+ module Sinatra
2
+ module Hat
3
+ # Used for specifying custom responses using a corny DSL.
4
+ class HashMutator
5
+ def initialize(hash)
6
+ @hash = hash
7
+ end
8
+
9
+ def success(&block)
10
+ @hash[:success] = block
11
+ end
12
+
13
+ def failure(&block)
14
+ @hash[:failure] = block
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,36 @@
1
+ module Sinatra
2
+ module Hat
3
+ # TODO This needs to be using Rack::CommonLogger
4
+ class Logger
5
+ def initialize(maker)
6
+ @maker = maker
7
+ end
8
+
9
+ def info(msg)
10
+ say msg
11
+ end
12
+
13
+ def debug(msg)
14
+ say msg
15
+ end
16
+
17
+ def warn(msg)
18
+ say msg
19
+ end
20
+
21
+ def error(msg)
22
+ say msg
23
+ end
24
+
25
+ def fatal(msg)
26
+ say msg
27
+ end
28
+
29
+ private
30
+
31
+ def say(msg)
32
+ puts msg if @maker.app and @maker.app.logging
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,187 @@
1
+ module Sinatra
2
+ module Hat
3
+ # This is where it all comes together
4
+ class Maker
5
+ include Sinatra::Hat::Extendor
6
+ include Sinatra::Authorization::Helpers
7
+
8
+ attr_reader :klass, :app
9
+
10
+ def self.actions
11
+ @actions ||= { }
12
+ end
13
+
14
+ def self.action(name, path, options={}, &block)
15
+ verb = options[:verb] || :get
16
+ Router.cache << [verb, name, path]
17
+ actions[name] = { :path => path, :verb => verb, :fn => block }
18
+ end
19
+
20
+ def self.option_setter(name, options={})
21
+ setter = options[:collection] ? 'Set.new(args)' : 'args.first'
22
+ class_eval(<<-END, __FILE__, __LINE__)
23
+ def #{name}(*args, &block)
24
+ return options[#{name.inspect}] = block if block_given?
25
+ return options[#{name.inspect}] = #{setter} unless args.empty?
26
+ return options[#{name.inspect}]
27
+ end
28
+ END
29
+ end
30
+
31
+ include Sinatra::Hat::Actions
32
+
33
+ # ======================================================
34
+
35
+ # The finder block is used when loading all records for the index
36
+ # action. It gets passed the model proxy and the request params hash.
37
+ option_setter :finder
38
+
39
+ # The finder block is used when loading a single record, which
40
+ # is the case for most actions. It gets passed the model proxy
41
+ # and the request params hash.
42
+ option_setter :record
43
+
44
+ # The authenticator block gets called before protected actions. It
45
+ # gets passed the basic auth username and password.
46
+ option_setter :authenticator
47
+
48
+ # A list of actions that get generated by this maker instance. By
49
+ # default it's all of the actions specified in actions.rb
50
+ option_setter :only, :collection => true
51
+
52
+ # A way to determine a record's representation in the database
53
+ option_setter :to_param
54
+
55
+ # The path prefix to use for generating groutes.
56
+ option_setter :prefix
57
+
58
+ option_setter :format
59
+
60
+ def initialize(klass, overrides={})
61
+ @klass = klass
62
+ options.merge!(overrides)
63
+ with(options)
64
+ end
65
+
66
+ # Simply stores the app instance when #mount is called.
67
+ def setup(app)
68
+ @app = app
69
+ end
70
+
71
+ # Processes a request, using the action specified in actions.rb
72
+ #
73
+ # TODO The work of handling a request should probably be wrapped
74
+ # up in a class.
75
+ def handle(action, request)
76
+ request.error(404) unless only.include?(action)
77
+ protect!(request) if protect.include?(action)
78
+
79
+ log_with_benchmark(request, action) do
80
+ instance_exec(request, &self.class.actions[action][:fn])
81
+ end
82
+ end
83
+
84
+ # Allows the DSL for specifying custom flow controls in a #mount
85
+ # block by altering the responder's defaults hash.
86
+ def after(action)
87
+ yield HashMutator.new(responder.defaults[action])
88
+ end
89
+
90
+ # A list of actions to protect via basic auth. Protected actions
91
+ # will have the authenticator block called before they are handled.
92
+ def protect(*actions)
93
+ credentials.merge!(actions.extract_options!)
94
+
95
+ if actions.empty?
96
+ options[:protect] ||= Set.new([])
97
+ else
98
+ actions == [:all] ?
99
+ Set.new(options[:protect] = only) :
100
+ Set.new(options[:protect] = actions)
101
+ end
102
+ end
103
+
104
+ # An array of parent Maker instances under which this instance
105
+ # was nested.
106
+ def parents
107
+ @parents ||= parent ? parent.parents + Array(parent) : []
108
+ end
109
+
110
+ # Looks up the resource path for the specified arguments using this
111
+ # maker's Resource instance.
112
+ def resource_path(*args)
113
+ resource.path(*args)
114
+ end
115
+
116
+ # Default options
117
+ def options
118
+ @options ||= {
119
+ :only => Set.new(Maker.actions.keys),
120
+ :parent => nil,
121
+ :format => nil,
122
+ :prefix => model.plural,
123
+ :finder => proc { |model, params| model.all },
124
+ :record => proc { |model, params| model.send("find_by_#{to_param}", params[:id]) },
125
+ :protect => [ ],
126
+ :formats => { },
127
+ :to_param => :id,
128
+ :credentials => { :username => 'username', :password => 'password', :realm => "The App" },
129
+ :authenticator => proc { |username, password| [username, password] == [:username, :password].map(&credentials.method(:[])) }
130
+ }
131
+ end
132
+
133
+ # Generates routes in the context of the given app.
134
+ def generate_routes!
135
+ Router.new(self).generate(@app)
136
+ end
137
+
138
+ # The responder determines what kind of response should used for
139
+ # a given action.
140
+ #
141
+ # TODO It might be better off to instantiate a new one of these per
142
+ # request, instead of having one per maker instance.
143
+ def responder
144
+ @responder ||= Responder.new(self)
145
+ end
146
+
147
+ # Handles ORM/model related logic.
148
+ def model
149
+ @model ||= Model.new(self)
150
+ end
151
+
152
+ # TODO Hook this into Rack::CommonLogger
153
+ def logger
154
+ @logger ||= Logger.new(self)
155
+ end
156
+
157
+ private
158
+
159
+ # Generates paths for this maker instance.
160
+ def resource
161
+ @resource ||= Resource.new(self)
162
+ end
163
+
164
+ def protected?(action)
165
+ protect.include?(action)
166
+ end
167
+
168
+ # Handles a request with logging and benchmarking.
169
+ def log_with_benchmark(request, action)
170
+ msg = [ ]
171
+ msg << "#{request.env['REQUEST_METHOD']} #{request.env['PATH_INFO']}"
172
+ msg << "Params: #{request.params.inspect}"
173
+ msg << "Action: #{action.to_s.upcase}"
174
+
175
+ logger.info "[sinatras-hat] " + msg.join(' | ')
176
+
177
+ result = nil
178
+
179
+ t = Benchmark.realtime { result = yield }
180
+
181
+ logger.info " Request finished in #{t} sec."
182
+
183
+ result
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,110 @@
1
+ module Sinatra
2
+ module Hat
3
+ # A wrapper around the model class that we're mounting
4
+ class Model
5
+ attr_reader :maker
6
+
7
+ delegate :options, :klass, :prefix, :to => :maker
8
+
9
+ def initialize(maker)
10
+ @maker = maker
11
+ end
12
+
13
+ # Loads all records using the maker's :finder option.
14
+ def all(params)
15
+ params.make_indifferent!
16
+ p = proxy(params)
17
+ if p
18
+ options[:finder].call(p, params)
19
+ else
20
+ nil
21
+ end
22
+ end
23
+
24
+ # Loads one record using the maker's :record option.
25
+ def find(params)
26
+ params.make_indifferent!
27
+ options[:record].call(proxy(params), params)
28
+ end
29
+
30
+ # Finds the owner record of a nested resource.
31
+ def find_owner(params)
32
+ params = parent_params(params)
33
+ options[:record].call(proxy(params), params)
34
+ end
35
+
36
+ # Updates a record with the given params.
37
+ def update(params)
38
+ if record = find(params)
39
+ params.nest!
40
+ record.attributes = (params[singular] || { })
41
+ record
42
+ end
43
+ end
44
+
45
+ # Returns a new instance of the mounted model.
46
+ def new(params={})
47
+ params.nest!
48
+ p = proxy(params)
49
+ if p
50
+ p.new(params[singular] || { })
51
+ else
52
+ nil
53
+ end
54
+ end
55
+
56
+ # Returns the pluralized name for the model.
57
+ def plural
58
+ klass.name.snake_case.plural
59
+ end
60
+
61
+ # Returns the singularized name for the model.
62
+ def singular
63
+ klass.name.snake_case.singular
64
+ end
65
+
66
+ # Returns the foreign_key to be used for this model.
67
+ def foreign_key
68
+ "#{singular}_id".to_sym
69
+ end
70
+
71
+ # Returns the last modified record from the array of records
72
+ # passed in. It's thorougly inefficient, since it requires all
73
+ # of the cacheable data to be loaded anyway.
74
+ def find_last_modified(records)
75
+ if records.all? { |r| r.respond_to?(:updated_at) }
76
+ records.sort_by { |r| r.updated_at }.last
77
+ else
78
+ records.last
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ # Returns an association proxy for a nested resource if available,
85
+ # otherwise it just returns nil
86
+ def proxy(params)
87
+ return klass unless parent
88
+ owner = parent.find_owner(params)
89
+ if owner and owner.respond_to?(plural)
90
+ owner.send(plural)
91
+ else
92
+ nil
93
+ end
94
+ end
95
+
96
+ # Dups and modifies params so that they can be used to find a parent.
97
+ def parent_params(params)
98
+ _params = params.dup.to_mash
99
+ _params.merge! :id => _params.delete(foreign_key)
100
+ _params
101
+ end
102
+
103
+ # Returns the parent model if there is one, otherwise nil.
104
+ def parent
105
+ return nil unless maker.parent
106
+ maker.parent.model
107
+ end
108
+ end
109
+ end
110
+ end