hanami 2.3.0.beta2 → 2.3.1

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.
@@ -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
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../routes"
4
-
5
3
  module Hanami
6
4
  class Slice
7
5
  # @api private
data/lib/hanami/slice.rb CHANGED
@@ -1044,6 +1044,7 @@ module Hanami
1044
1044
 
1045
1045
  Slice::Router.new(
1046
1046
  inspector: inspector,
1047
+ inflector: inflector,
1047
1048
  routes: routes,
1048
1049
  resolver: config.router.resolver.new(slice: self),
1049
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.beta2"
10
+ VERSION = "2.3.1"
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
 
@@ -68,74 +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.register :json, "application/json",
80
- accept_types: ["application/json", "application/json+scim"],
81
- content_types: ["application/json", "application/json+scim"]
82
- config.actions.formats.accept :json
83
- end
84
- end
85
- RUBY
86
-
87
- write "config/routes.rb", <<~RUBY
88
- module TestApp
89
- class Routes < Hanami::Routes
90
- post "/users", to: "users.create"
91
- end
92
- end
93
- RUBY
94
-
95
- write "app/action.rb", <<~RUBY
96
- # auto_register: false
97
-
98
- module TestApp
99
- class Action < Hanami::Action
100
- end
101
- end
102
- RUBY
103
-
104
- write "app/actions/users/create.rb", <<~RUBY
105
- module TestApp
106
- module Actions
107
- module Users
108
- class Create < TestApp::Action
109
- def handle(req, res)
110
- res.body = req.params[:users].join("-")
111
- end
112
- end
113
- end
114
- end
115
- end
116
- RUBY
117
-
118
- require "hanami/boot"
119
-
120
- post(
121
- "/users",
122
- JSON.generate("users" => %w[jane john jade joe]),
123
- "CONTENT_TYPE" => "application/json+scim"
124
- )
125
-
126
- expect(last_response).to be_successful
127
- expect(last_response.body).to eql("jane-john-jade-joe")
128
-
129
- post(
130
- "/users",
131
- JSON.generate("users" => %w[jane john jade joe]),
132
- "CONTENT_TYPE" => "application/json"
133
- )
134
-
135
- expect(last_response).to be_successful
136
- expect(last_response.body).to eql("jane-john-jade-joe")
137
- end
138
-
139
71
  it "does not add a body parser middleware if one is already added" do
140
72
  write "config/app.rb", <<~RUBY
141
73
  require "hanami"
@@ -11,10 +11,13 @@ RSpec.describe "Logging / Request logging", :app_integration do
11
11
 
12
12
  let(:logger_stream) { StringIO.new }
13
13
 
14
+ let(:logger_level) { nil }
15
+
14
16
  let(:root) { make_tmp_directory }
15
17
 
16
18
  def configure_logger
17
19
  Hanami.app.config.logger.stream = logger_stream
20
+ Hanami.app.config.logger.level = logger_level if logger_level
18
21
  end
19
22
 
20
23
  def logs
@@ -84,6 +87,19 @@ RSpec.describe "Logging / Request logging", :app_integration do
84
87
  )
85
88
  end
86
89
  end
90
+
91
+ context "log level error" do
92
+ let(:logger_level) { :error }
93
+ before do
94
+ expect_any_instance_of(Hanami::Web::RackLogger).to_not receive(:data)
95
+ end
96
+
97
+ it "does not log info" do
98
+ get "/"
99
+
100
+ expect(logs.split("\n").length).to eq 0
101
+ end
102
+ end
87
103
  end
88
104
 
89
105
  describe "slice router" do
@@ -56,4 +56,67 @@ RSpec.describe "Operation / Extensions", :app_integration do
56
56
  expect(main.rom).to be Main::Slice["db.rom"]
57
57
  end
58
58
  end
59
+
60
+ context "hanami-db bundled, but no db configured" do
61
+ it "does not extend the operation class" do
62
+ with_tmp_directory(Dir.mktmpdir) do
63
+ write "config/app.rb", <<~RUBY
64
+ require "hanami"
65
+
66
+ module TestApp
67
+ class App < Hanami::App
68
+ end
69
+ end
70
+ RUBY
71
+
72
+ write "app/operation.rb", <<~RUBY
73
+ module TestApp
74
+ class Operation < Dry::Operation
75
+ end
76
+ end
77
+ RUBY
78
+
79
+ require "hanami/prepare"
80
+
81
+ operation = TestApp::Operation.new
82
+
83
+ expect(operation.rom).to be nil
84
+ expect { operation.transaction }.to raise_error Hanami::ComponentLoadError, "A configured db for TestApp::App is required to run transactions."
85
+ end
86
+ end
87
+ end
88
+
89
+ context "hanami-db not bundled" do
90
+ before do
91
+ allow(Hanami).to receive(:bundled?).and_call_original
92
+ allow(Hanami).to receive(:bundled?).with("hanami-db").and_return false
93
+ end
94
+
95
+ it "does not extend the operation class" do
96
+ with_tmp_directory(Dir.mktmpdir) do
97
+ write "config/app.rb", <<~RUBY
98
+ require "hanami"
99
+
100
+ module TestApp
101
+ class App < Hanami::App
102
+ end
103
+ end
104
+ RUBY
105
+
106
+ write "app/operation.rb", <<~RUBY
107
+ module TestApp
108
+ class Operation < Dry::Operation
109
+ end
110
+ end
111
+ RUBY
112
+
113
+ require "hanami/prepare"
114
+
115
+ operation = TestApp::Operation.new
116
+
117
+ expect { operation.rom }.to raise_error NoMethodError
118
+ expect { operation.transaction }.to raise_error NoMethodError
119
+ end
120
+ end
121
+ end
59
122
  end
@@ -12,14 +12,13 @@ RSpec.describe "Hanami web app", :app_integration do
12
12
  with_tmp_directory(Dir.mktmpdir, &example)
13
13
  end
14
14
 
15
- specify "Setting middlewares in the config" do
15
+ specify "Default body parser" do
16
16
  write "config/app.rb", <<~RUBY
17
17
  require "hanami"
18
18
 
19
19
  module TestApp
20
20
  class App < Hanami::App
21
21
  config.actions.format :json
22
- config.middleware.use :body_parser, :json
23
22
  config.logger.stream = StringIO.new
24
23
  end
25
24
  end