hanami 2.3.0.beta1 → 2.3.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/README.md +1 -3
  4. data/hanami.gemspec +8 -8
  5. data/lib/hanami/config/actions/content_security_policy.rb +2 -2
  6. data/lib/hanami/config/actions.rb +3 -2
  7. data/lib/hanami/config/router.rb +1 -0
  8. data/lib/hanami/config.rb +11 -19
  9. data/lib/hanami/extensions/action.rb +1 -0
  10. data/lib/hanami/extensions/operation/slice_configured_db_operation.rb +88 -0
  11. data/lib/hanami/extensions/operation.rb +8 -23
  12. data/lib/hanami/extensions/view/context.rb +2 -11
  13. data/lib/hanami/extensions/view/part.rb +3 -0
  14. data/lib/hanami/extensions/view/scope.rb +3 -0
  15. data/lib/hanami/extensions/view/slice_configured_context.rb +0 -7
  16. data/lib/hanami/extensions/view.rb +1 -0
  17. data/lib/hanami/helpers/assets_helper.rb +2 -2
  18. data/lib/hanami/middleware/content_security_policy_nonce.rb +3 -3
  19. data/lib/hanami/routes.rb +4 -3
  20. data/lib/hanami/slice/router.rb +201 -12
  21. data/lib/hanami/slice.rb +13 -0
  22. data/lib/hanami/slice_configurable.rb +1 -3
  23. data/lib/hanami/version.rb +1 -1
  24. data/lib/hanami/web/rack_logger.rb +25 -8
  25. data/lib/hanami.rb +15 -1
  26. data/spec/integration/action/format_config_spec.rb +2 -67
  27. data/spec/integration/action/slice_configuration_spec.rb +36 -36
  28. data/spec/integration/logging/request_logging_spec.rb +16 -0
  29. data/spec/integration/operations/extension_spec.rb +63 -0
  30. data/spec/integration/rack_app/body_parser_spec.rb +3 -3
  31. data/spec/integration/rack_app/rack_app_spec.rb +2 -2
  32. data/spec/integration/router/resource_routes_spec.rb +281 -0
  33. data/spec/unit/hanami/config/actions_spec.rb +2 -2
  34. metadata +19 -20
  35. data/spec/integration/view/context/settings_spec.rb +0 -46
  36. data/spec/unit/hanami/version_spec.rb +0 -7
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "hanami/router"
4
+ require_relative "routing/resolver"
5
+ require_relative "routing/middleware/stack"
4
6
 
5
7
  module Hanami
6
8
  class Slice
@@ -10,27 +12,27 @@ module Hanami
10
12
  # {Hanami::Slice::ClassMethods#router router}.
11
13
  #
12
14
  # @api private
13
- # @since 2.0.0
14
15
  class Router < ::Hanami::Router
15
16
  # @api private
16
- # @since 2.0.0
17
+ attr_reader :inflector
18
+
19
+ # @api private
17
20
  attr_reader :middleware_stack
18
21
 
19
22
  # @api private
20
- # @since 2.0.0
21
23
  attr_reader :path_prefix
22
24
 
23
25
  # @api private
24
- # @since 2.0.0
25
- def initialize(routes:, middleware_stack: Routing::Middleware::Stack.new, prefix: ::Hanami::Router::DEFAULT_PREFIX, **kwargs, &blk)
26
+ def initialize(routes:, inflector:, middleware_stack: Routing::Middleware::Stack.new, prefix: ::Hanami::Router::DEFAULT_PREFIX, **kwargs, &blk)
26
27
  @path_prefix = Hanami::Router::Prefix.new(prefix)
28
+ @inflector = inflector
27
29
  @middleware_stack = middleware_stack
30
+ @resource_scope = []
28
31
  instance_eval(&blk)
29
32
  super(**kwargs, &routes)
30
33
  end
31
34
 
32
35
  # @api private
33
- # @since 2.0.0
34
36
  def freeze
35
37
  return self if frozen?
36
38
 
@@ -39,7 +41,11 @@ module Hanami
39
41
  end
40
42
 
41
43
  # @api private
42
- # @since 2.0.0
44
+ def to_rack_app
45
+ middleware_stack.to_rack_app(self)
46
+ end
47
+
48
+ # @api private
43
49
  def use(*args, **kwargs, &blk)
44
50
  middleware_stack.use(*args, **kwargs.merge(path_prefix: path_prefix.to_s), &blk)
45
51
  end
@@ -65,21 +71,204 @@ module Hanami
65
71
  #
66
72
  # @api public
67
73
  # @since 2.0.0
68
- def slice(slice_name, at:, &blk)
74
+ def slice(slice_name, at:, as: nil, &blk)
69
75
  blk ||= @resolver.find_slice(slice_name).routes
70
76
 
71
77
  prev_resolver = @resolver
72
78
  @resolver = @resolver.to_slice(slice_name)
73
79
 
74
- scope(at, &blk)
80
+ scope(at, as:, &blk)
75
81
  ensure
76
82
  @resolver = prev_resolver
77
83
  end
78
84
 
85
+ # Generates RESTful routes for a plural resource.
86
+ #
87
+ # @param name [Symbol] the resource name (plural)
88
+ # @param options [Hash] options for customizing the routes
89
+ # @option options [Array<Symbol>] :only Limit to specific actions
90
+ # @option options [Array<Symbol>] :except Exclude specific actions
91
+ # @option options [String] :to the action key namespace, e.g. "namespace.action"
92
+ # @option options [String] :path the URL path
93
+ # @option options [String, Symbol] :as the route name prefix
94
+ #
95
+ # @example
96
+ # resources :users
97
+ # # Generates:
98
+ # # GET /users users.index
99
+ # # GET /users/new users.new
100
+ # # POST /users users.create
101
+ # # GET /users/:id users.show
102
+ # # GET /users/:id/edit users.edit
103
+ # # PATCH /users/:id users.update
104
+ # # DELETE /users/:id users.destroy
105
+ #
106
+ # @api public
107
+ # @since 2.3.0
108
+ def resources(name, **options, &block)
109
+ build_resource(name, :plural, options, &block)
110
+ end
111
+
112
+ # Generates RESTful routes for a singular resource.
113
+ #
114
+ # @param name [Symbol] the resource name (singular)
115
+ # @param options [Hash] options for customizing the routes
116
+ # @option options [Array<Symbol>] :only limit to specific actions
117
+ # @option options [Array<Symbol>] :except exclude specific actions
118
+ # @option options [String] :to the action key namespace, e.g. "namespace.action"
119
+ # @option options [String] :path the URL path
120
+ # @option options [String, Symbol] :as the route name prefix
121
+ #
122
+ # @example
123
+ # resource :profile
124
+ # # Generates (singular, no index):
125
+ # # GET /profile/new profile.new
126
+ # # POST /profile profile.create
127
+ # # GET /profile profile.show
128
+ # # GET /profile/edit profile.edit
129
+ # # PATCH /profile profile.update
130
+ # # DELETE /profile profile.destroy
131
+ #
132
+ # @api public
133
+ # @since 2.3.0
134
+ def resource(name, **options, &block)
135
+ build_resource(name, :singular, options, &block)
136
+ end
137
+
138
+ private
139
+
140
+ def build_resource(name, type, options, &block)
141
+ resource_builder = ResourceBuilder.new(
142
+ name: name,
143
+ type: type,
144
+ resource_scope: @resource_scope,
145
+ options: options,
146
+ inflector: inflector
147
+ )
148
+
149
+ resource_builder.add_routes(self)
150
+ resource_builder.scope(self, &block) if block
151
+ end
152
+
153
+ # Builds RESTful routes for a resource
154
+ #
79
155
  # @api private
80
- # @since 2.0.0
81
- def to_rack_app
82
- middleware_stack.to_rack_app(self)
156
+ class ResourceBuilder
157
+ ROUTE_OPTIONS = {
158
+ index: {method: :get},
159
+ new: {method: :get, path_suffix: "/new", name_prefix: "new"},
160
+ create: {method: :post},
161
+ show: {method: :get, path_suffix: "/:id"},
162
+ edit: {method: :get, path_suffix: "/:id/edit", name_prefix: "edit"},
163
+ update: {method: :patch, path_suffix: "/:id"},
164
+ destroy: {method: :delete, path_suffix: "/:id"}
165
+ }.freeze
166
+
167
+ PLURAL_ACTIONS = %i[index new create show edit update destroy].freeze
168
+ SINGULAR_ACTIONS = (PLURAL_ACTIONS - %i[index]).freeze
169
+
170
+ def initialize(name:, type:, resource_scope:, options:, inflector:)
171
+ @name = name
172
+ @type = type
173
+ @resource_scope = resource_scope
174
+ @options = options
175
+ @inflector = inflector
176
+
177
+ @path = options[:path] || name.to_s
178
+ end
179
+
180
+ def scope(router, &block)
181
+ @resource_scope.push(@name)
182
+ router.scope(scope_path, as: scope_name, &block)
183
+ ensure
184
+ @resource_scope.pop
185
+ end
186
+
187
+ def add_routes(router)
188
+ actions.each do |action|
189
+ add_route(router, action, ROUTE_OPTIONS.fetch(action))
190
+ end
191
+ end
192
+
193
+ private
194
+
195
+ def plural?
196
+ @type == :plural
197
+ end
198
+
199
+ def singular?
200
+ !plural?
201
+ end
202
+
203
+ def scope_path
204
+ if plural?
205
+ "#{@path}/:#{@inflector.singularize(@path.to_s)}_id"
206
+ else
207
+ @path
208
+ end
209
+ end
210
+
211
+ def scope_name
212
+ @inflector.singularize(@name)
213
+ end
214
+
215
+ def actions
216
+ default_actions = plural? ? PLURAL_ACTIONS : SINGULAR_ACTIONS
217
+ if @options[:only]
218
+ Array(@options[:only]) & default_actions
219
+ elsif @options[:except]
220
+ default_actions - Array(@options[:except])
221
+ else
222
+ default_actions
223
+ end
224
+ end
225
+
226
+ def add_route(router, action, route_options)
227
+ path = "/#{@path}#{route_suffix(route_options[:path_suffix])}"
228
+ to = "#{key_path_base}#{CONTAINER_KEY_DELIMITER}#{action}"
229
+ as = route_name(action, route_options[:name_prefix])
230
+
231
+ router.public_send(route_options[:method], path, to:, as:)
232
+ end
233
+
234
+ def route_suffix(suffix)
235
+ return suffix.sub(LEADING_ID_REGEX, "") if suffix && singular?
236
+ suffix
237
+ end
238
+ LEADING_ID_REGEX = %r{\A/:id}
239
+
240
+ def key_path_base
241
+ @key_path_base ||=
242
+ if @options[:to]
243
+ @options[:to]
244
+ else
245
+ @name.to_s.then { |name|
246
+ next name unless @resource_scope.any?
247
+
248
+ prefix = @resource_scope.join(CONTAINER_KEY_DELIMITER)
249
+ "#{prefix}#{CONTAINER_KEY_DELIMITER}#{name}"
250
+ }
251
+ end
252
+ end
253
+
254
+ def route_name(action, prefix)
255
+ name = route_name_base
256
+ name = @inflector.pluralize(name) if plural? && PLURALIZED_NAME_ACTIONS.include?(action)
257
+
258
+ [prefix, name]
259
+ end
260
+ PLURALIZED_NAME_ACTIONS = %i[index create].freeze
261
+
262
+ def route_name_base
263
+ @route_name_base ||=
264
+ if @options[:as]
265
+ @options[:as].to_s
266
+ elsif plural?
267
+ @inflector.singularize(@name.to_s)
268
+ else
269
+ @name.to_s
270
+ end
271
+ end
83
272
  end
84
273
  end
85
274
  end
data/lib/hanami/slice.rb CHANGED
@@ -223,6 +223,18 @@ module Hanami
223
223
  app? ? root.join(APP_DIR) : root
224
224
  end
225
225
 
226
+ # Returns the slice's root component directory, as a path relative to the app's root.
227
+ #
228
+ # @return [Pathname]
229
+ #
230
+ # @see #source_path
231
+ #
232
+ # @api public
233
+ # @since 2.3.0
234
+ def relative_source_path
235
+ source_path.relative_path_from(app.root)
236
+ end
237
+
226
238
  # Returns the slice's configured inflector.
227
239
  #
228
240
  # Unless explicitly re-configured for the slice, this will be the app's inflector.
@@ -1032,6 +1044,7 @@ module Hanami
1032
1044
 
1033
1045
  Slice::Router.new(
1034
1046
  inspector: inspector,
1047
+ inflector: inflector,
1035
1048
  routes: routes,
1036
1049
  resolver: config.router.resolver.new(slice: self),
1037
1050
  **error_handlers,
@@ -43,7 +43,7 @@ module Hanami
43
43
  return unless slice
44
44
 
45
45
  unless subclass.configured_for_slice?(slice)
46
- subclass.configure_for_slice(slice)
46
+ subclass.configure_for_slice(slice) if subclass.respond_to?(:configure_for_slice)
47
47
  subclass.configured_for_slices << slice
48
48
  end
49
49
  end
@@ -63,8 +63,6 @@ module Hanami
63
63
  end
64
64
  end
65
65
 
66
- def configure_for_slice(slice); end
67
-
68
66
  def configured_for_slice?(slice)
69
67
  configured_for_slices.include?(slice)
70
68
  end
@@ -7,7 +7,7 @@ module Hanami
7
7
  # @api private
8
8
  module Version
9
9
  # @api public
10
- VERSION = "2.3.0.beta1"
10
+ VERSION = "2.3.0"
11
11
 
12
12
  # @since 0.9.0
13
13
  # @api private
@@ -106,18 +106,30 @@ module Hanami
106
106
  #
107
107
  # @since 2.1.0
108
108
  # @api private
109
- def info(message = nil, **payload)
110
- payload[:message] = message if message
111
- logger.info(JSON.fast_generate(payload))
109
+ def info(message = nil, **payload, &blk)
110
+ logger.info do
111
+ if blk
112
+ JSON.generate(blk.call)
113
+ else
114
+ payload[:message] = message if message
115
+ JSON.generate(payload)
116
+ end
117
+ end
112
118
  end
113
119
 
114
120
  # @see info
115
121
  #
116
122
  # @since 2.1.0
117
123
  # @api private
118
- def error(message = nil, **payload)
119
- payload[:message] = message if message
120
- logger.info(JSON.fast_generate(payload))
124
+ def error(message = nil, **payload, &blk)
125
+ logger.error do
126
+ if blk
127
+ JSON.generate(blk.call)
128
+ else
129
+ payload[:message] = message if message
130
+ JSON.generate(payload)
131
+ end
132
+ end
121
133
  end
122
134
  end
123
135
 
@@ -145,7 +157,10 @@ module Hanami
145
157
  # @since 2.0.0
146
158
  def log_request(env, status, elapsed)
147
159
  logger.tagged(:rack) do
148
- logger.info(**data(env, status: status, elapsed: elapsed))
160
+
161
+ logger.info do
162
+ data(env, status: status, elapsed: elapsed)
163
+ end
149
164
  end
150
165
  end
151
166
 
@@ -153,7 +168,9 @@ module Hanami
153
168
  # @since 2.0.0
154
169
  def log_exception(env, exception, status, elapsed)
155
170
  logger.tagged(:rack) do
156
- logger.error(exception, **data(env, status: status, elapsed: elapsed))
171
+ logger.error(exception) do
172
+ data(env, status: status, elapsed: elapsed)
173
+ end
157
174
  end
158
175
  end
159
176
 
data/lib/hanami.rb CHANGED
@@ -19,10 +19,24 @@ module Hanami
19
19
  @loader ||= Zeitwerk::Loader.for_gem.tap do |loader|
20
20
  loader.inflector.inflect "db" => "DB"
21
21
  loader.inflector.inflect "db_logging" => "DBLogging"
22
+ loader.inflector.inflect "slice_configured_db_operation" => "SliceConfiguredDBOperation"
22
23
  loader.inflector.inflect "sql_adapter" => "SQLAdapter"
24
+
25
+ gem_lib = loader.dirs.first
23
26
  loader.ignore(
24
- "#{loader.dirs.first}/hanami/{constants,boot,errors,extensions/router/errors,prepare,rake_tasks,setup}.rb"
27
+ "#{gem_lib}/hanami/{constants,boot,errors,extensions/router/errors,prepare,rake_tasks,setup}.rb",
28
+ # Ignore conditionally-loaded classes dependent on gems that may not be included in the
29
+ # user's Gemfile
30
+ "#{gem_lib}/hanami/config/{assets,router,views}.rb",
31
+ "#{gem_lib}/hanami/slice/router.rb",
32
+ "#{gem_lib}/hanami/slice/routing/resolver.rb",
33
+ "#{gem_lib}/hanami/slice/routing/middleware/stack.rb",
34
+ "#{gem_lib}/hanami/extensions/**/*"
25
35
  )
36
+
37
+ unless Hanami.bundled?("hanami-router")
38
+ loader.ignore("#{gem_lib}/hanami/routes.rb")
39
+ end
26
40
  end
27
41
  end
28
42
 
@@ -20,7 +20,7 @@ RSpec.describe "App action / Format config", :app_integration do
20
20
  class App < Hanami::App
21
21
  config.logger.stream = StringIO.new
22
22
 
23
- config.actions.format :json
23
+ config.actions.formats.accept :json
24
24
  end
25
25
  end
26
26
  RUBY
@@ -68,71 +68,6 @@ RSpec.describe "App action / Format config", :app_integration do
68
68
  expect(last_response.body).to eql("jane-john-jade-joe")
69
69
  end
70
70
 
71
- specify "adds a body parser middleware configured to parse any custom content type for the accepted formats" do
72
- write "config/app.rb", <<~RUBY
73
- require "hanami"
74
-
75
- module TestApp
76
- class App < Hanami::App
77
- config.logger.stream = StringIO.new
78
-
79
- config.actions.formats.add :json, ["application/json+scim", "application/json"]
80
- end
81
- end
82
- RUBY
83
-
84
- write "config/routes.rb", <<~RUBY
85
- module TestApp
86
- class Routes < Hanami::Routes
87
- post "/users", to: "users.create"
88
- end
89
- end
90
- RUBY
91
-
92
- write "app/action.rb", <<~RUBY
93
- # auto_register: false
94
-
95
- module TestApp
96
- class Action < Hanami::Action
97
- end
98
- end
99
- RUBY
100
-
101
- write "app/actions/users/create.rb", <<~RUBY
102
- module TestApp
103
- module Actions
104
- module Users
105
- class Create < TestApp::Action
106
- def handle(req, res)
107
- res.body = req.params[:users].join("-")
108
- end
109
- end
110
- end
111
- end
112
- end
113
- RUBY
114
-
115
- require "hanami/boot"
116
-
117
- post(
118
- "/users",
119
- JSON.generate("users" => %w[jane john jade joe]),
120
- "CONTENT_TYPE" => "application/json+scim"
121
- )
122
-
123
- expect(last_response).to be_successful
124
- expect(last_response.body).to eql("jane-john-jade-joe")
125
-
126
- post(
127
- "/users",
128
- JSON.generate("users" => %w[jane john jade joe]),
129
- "CONTENT_TYPE" => "application/json"
130
- )
131
-
132
- expect(last_response).to be_successful
133
- expect(last_response.body).to eql("jane-john-jade-joe")
134
- end
135
-
136
71
  it "does not add a body parser middleware if one is already added" do
137
72
  write "config/app.rb", <<~RUBY
138
73
  require "hanami"
@@ -141,7 +76,7 @@ RSpec.describe "App action / Format config", :app_integration do
141
76
  class App < Hanami::App
142
77
  config.logger.stream = StringIO.new
143
78
 
144
- config.actions.format :json
79
+ config.actions.formats.accept :json
145
80
  config.middleware.use :body_parser, [json: "application/json+custom"]
146
81
  end
147
82
  end