hanami 2.2.0 → 2.3.0.beta1

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -1
  3. data/README.md +20 -35
  4. data/hanami.gemspec +3 -2
  5. data/lib/hanami/app.rb +2 -0
  6. data/lib/hanami/config/actions/content_security_policy.rb +23 -0
  7. data/lib/hanami/config/actions.rb +21 -0
  8. data/lib/hanami/config/console.rb +79 -0
  9. data/lib/hanami/config/logger.rb +1 -1
  10. data/lib/hanami/config.rb +13 -0
  11. data/lib/hanami/constants.rb +3 -0
  12. data/lib/hanami/extensions/db/repo.rb +11 -6
  13. data/lib/hanami/extensions/operation.rb +1 -1
  14. data/lib/hanami/extensions/view/context.rb +10 -0
  15. data/lib/hanami/helpers/assets_helper.rb +92 -25
  16. data/lib/hanami/middleware/content_security_policy_nonce.rb +53 -0
  17. data/lib/hanami/slice.rb +22 -6
  18. data/lib/hanami/slice_registrar.rb +1 -1
  19. data/lib/hanami/version.rb +1 -1
  20. data/lib/hanami.rb +10 -2
  21. data/spec/integration/assets/cross_slice_assets_helpers_spec.rb +0 -1
  22. data/spec/integration/assets/serve_static_assets_spec.rb +1 -1
  23. data/spec/integration/container/autoloader_spec.rb +2 -0
  24. data/spec/integration/db/db_spec.rb +1 -1
  25. data/spec/integration/db/logging_spec.rb +63 -0
  26. data/spec/integration/db/repo_spec.rb +87 -2
  27. data/spec/integration/logging/exception_logging_spec.rb +6 -1
  28. data/spec/integration/rack_app/middleware_spec.rb +4 -11
  29. data/spec/integration/view/helpers/form_helper_spec.rb +1 -1
  30. data/spec/integration/web/content_security_policy_nonce_spec.rb +251 -0
  31. data/spec/support/app_integration.rb +2 -1
  32. data/spec/unit/hanami/config/actions/content_security_policy_spec.rb +7 -0
  33. data/spec/unit/hanami/config/console_spec.rb +22 -0
  34. data/spec/unit/hanami/env_spec.rb +10 -13
  35. data/spec/unit/hanami/slice_spec.rb +18 -0
  36. data/spec/unit/hanami/version_spec.rb +1 -1
  37. data/spec/unit/hanami/web/rack_logger_spec.rb +11 -4
  38. metadata +27 -18
  39. data/spec/support/shared_examples/cli/generate/app.rb +0 -494
  40. data/spec/support/shared_examples/cli/generate/migration.rb +0 -32
  41. data/spec/support/shared_examples/cli/generate/model.rb +0 -81
  42. data/spec/support/shared_examples/cli/new.rb +0 -97
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "uri"
4
4
  require "hanami/view"
5
+ require_relative "../constants"
5
6
 
6
7
  # rubocop:disable Metrics/ModuleLength
7
8
 
@@ -61,7 +62,10 @@ module Hanami
61
62
 
62
63
  # @since 0.3.0
63
64
  # @api private
64
- ABSOLUTE_URL_MATCHER = URI::DEFAULT_PARSER.make_regexp
65
+ # TODO: we can drop the defined?-check and fallback once Ruby 3.3 becomes our minimum required version
66
+ ABSOLUTE_URL_MATCHER = (
67
+ defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::DEFAULT_PARSER
68
+ ).make_regexp
65
69
 
66
70
  # @since 1.1.0
67
71
  # @api private
@@ -85,7 +89,12 @@ module Hanami
85
89
  # name of the algorithm, then a hyphen, then the hash value of the file.
86
90
  # If more than one algorithm is used, they"ll be separated by a space.
87
91
  #
88
- # @param source_paths [Array<String, #url>] one or more assets by name or absolute URL
92
+ # If the Content Security Policy uses 'nonce' and the source is not
93
+ # absolute, the nonce value of the current request is automatically added
94
+ # as an attribute. You can override this with the `nonce: false` option.
95
+ # See {#content_security_policy_nonce} for more.
96
+ #
97
+ # @param sources [Array<String, #url>] one or more assets by name or absolute URL
89
98
  #
90
99
  # @return [Hanami::View::HTML::SafeString] the markup
91
100
  #
@@ -136,6 +145,10 @@ module Hanami
136
145
  #
137
146
  # # <script src="/assets/application.js" type="text/javascript" defer="defer"></script>
138
147
  #
148
+ # @example Disable nonce
149
+ #
150
+ # <%= javascript_tag "application", nonce: false %>
151
+ #
139
152
  # @example Absolute URL
140
153
  #
141
154
  # <%= javascript_tag "https://code.jquery.com/jquery-2.1.4.min.js" %>
@@ -154,13 +167,15 @@ module Hanami
154
167
  #
155
168
  # # <script src="https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js"
156
169
  # # type="text/javascript"></script>
157
- def javascript_tag(*source_paths, **options)
170
+ def javascript_tag(*sources, **options)
158
171
  options = options.reject { |k, _| k.to_sym == :src }
172
+ nonce_option = options.delete(:nonce)
159
173
 
160
- _safe_tags(*source_paths) do |source|
174
+ _safe_tags(*sources) do |source|
161
175
  attributes = {
162
- src: _typed_path(source, JAVASCRIPT_EXT),
163
- type: JAVASCRIPT_MIME_TYPE
176
+ src: _typed_url(source, JAVASCRIPT_EXT),
177
+ type: JAVASCRIPT_MIME_TYPE,
178
+ nonce: _nonce(source, nonce_option)
164
179
  }
165
180
  attributes.merge!(options)
166
181
 
@@ -189,7 +204,12 @@ module Hanami
189
204
  # name of the algorithm, then a hyphen, then the hashed value of the file.
190
205
  # If more than one algorithm is used, they"ll be separated by a space.
191
206
  #
192
- # @param source_paths [Array<String, #url>] one or more assets by name or absolute URL
207
+ # If the Content Security Policy uses 'nonce' and the source is not
208
+ # absolute, the nonce value of the current request is automatically added
209
+ # as an attribute. You can override this with the `nonce: false` option.
210
+ # See {#content_security_policy_nonce} for more.
211
+ #
212
+ # @param sources [Array<String, #url>] one or more assets by name or absolute URL
193
213
  #
194
214
  # @return [Hanami::View::HTML::SafeString] the markup
195
215
  #
@@ -214,6 +234,10 @@ module Hanami
214
234
  # # <link href="/assets/application.css" type="text/css" rel="stylesheet">
215
235
  # # <link href="/assets/dashboard.css" type="text/css" rel="stylesheet">
216
236
  #
237
+ # @example Disable nonce
238
+ #
239
+ # <%= stylesheet_tag "application", nonce: false %>
240
+ #
217
241
  # @example Subresource Integrity
218
242
  #
219
243
  # <%= stylesheet_tag "application" %>
@@ -247,19 +271,21 @@ module Hanami
247
271
  #
248
272
  # # <link href="https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.css"
249
273
  # # type="text/css" rel="stylesheet">
250
- def stylesheet_tag(*source_paths, **options)
274
+ def stylesheet_tag(*sources, **options)
251
275
  options = options.reject { |k, _| k.to_sym == :href }
276
+ nonce_option = options.delete(:nonce)
252
277
 
253
- _safe_tags(*source_paths) do |source_path|
278
+ _safe_tags(*sources) do |source|
254
279
  attributes = {
255
- href: _typed_path(source_path, STYLESHEET_EXT),
280
+ href: _typed_url(source, STYLESHEET_EXT),
256
281
  type: STYLESHEET_MIME_TYPE,
257
- rel: STYLESHEET_REL
282
+ rel: STYLESHEET_REL,
283
+ nonce: _nonce(source, nonce_option)
258
284
  }
259
285
  attributes.merge!(options)
260
286
 
261
287
  if _context.assets.subresource_integrity? || attributes.include?(:integrity)
262
- attributes[:integrity] ||= _subresource_integrity_value(source_path, STYLESHEET_EXT)
288
+ attributes[:integrity] ||= _subresource_integrity_value(source, STYLESHEET_EXT)
263
289
  attributes[:crossorigin] ||= CROSSORIGIN_ANONYMOUS
264
290
  end
265
291
 
@@ -626,7 +652,7 @@ module Hanami
626
652
  #
627
653
  # If CDN mode is on, it returns the absolute URL of the asset.
628
654
  #
629
- # @param source_path [String, #url] the asset name or asset object
655
+ # @param source [String, #url] the asset name or asset object
630
656
  #
631
657
  # @return [String] the asset path
632
658
  #
@@ -665,42 +691,71 @@ module Hanami
665
691
  # <%= asset_url "application.js" %>
666
692
  #
667
693
  # # "https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js"
668
- def asset_url(source_path)
669
- return source_path.url if source_path.respond_to?(:url)
670
- return source_path if _absolute_url?(source_path)
694
+ def asset_url(source)
695
+ return source.url if source.respond_to?(:url)
696
+ return source if _absolute_url?(source)
671
697
 
672
- _context.assets[source_path].url
698
+ _context.assets[source].url
699
+ end
700
+
701
+ # Random per request nonce value for Content Security Policy (CSP) rules.
702
+ #
703
+ # If the `Hanami::Middleware::ContentSecurityPolicyNonce` middleware is
704
+ # in use, this helper returns the nonce value for the current request
705
+ # or `nil` otherwise.
706
+ #
707
+ # For this policy to work in the browser, you have to add the `'nonce'`
708
+ # placeholder to the script and/or style source policy rule. It will be
709
+ # substituted by the current nonce value like `'nonce-A12OggyZ'.
710
+ #
711
+ # @return [String, nil] nonce value of the current request
712
+ #
713
+ # @since x.x.x
714
+ #
715
+ # @example App configuration
716
+ #
717
+ # config.middleware.use Hanami::Middleware::ContentSecurityPolicyNonce
718
+ # config.actions.content_security_policy[:script_src] = "'self' 'nonce'"
719
+ # config.actions.content_security_policy[:style_src] = "'self' 'nonce'"
720
+ #
721
+ # @example View helper
722
+ #
723
+ # <script nonce="<%= content_security_policy_nonce %>">
724
+ def content_security_policy_nonce
725
+ return unless _context.request?
726
+
727
+ _context.request.env[CONTENT_SECURITY_POLICY_NONCE_REQUEST_KEY]
673
728
  end
674
729
 
675
730
  private
676
731
 
677
732
  # @since 2.1.0
678
733
  # @api private
679
- def _safe_tags(*source_paths, &blk)
734
+ def _safe_tags(*sources, &blk)
680
735
  ::Hanami::View::HTML::SafeString.new(
681
- source_paths.map(&blk).join(NEW_LINE_SEPARATOR)
736
+ sources.map(&blk).join(NEW_LINE_SEPARATOR)
682
737
  )
683
738
  end
684
739
 
685
740
  # @since 2.1.0
686
741
  # @api private
687
- def _typed_path(source, ext)
742
+ def _typed_url(source, ext)
688
743
  source = "#{source}#{ext}" if source.is_a?(String) && _append_extension?(source, ext)
689
744
  asset_url(source)
690
745
  end
691
746
 
692
747
  # @api private
693
- def _subresource_integrity_value(source_path, ext)
694
- return if _absolute_url?(source_path)
748
+ def _subresource_integrity_value(source, ext)
749
+ return if _absolute_url?(source)
695
750
 
696
- source_path = "#{source_path}#{ext}" unless /#{Regexp.escape(ext)}\z/.match?(source_path)
697
- _context.assets[source_path].sri
751
+ source = "#{source}#{ext}" unless /#{Regexp.escape(ext)}\z/.match?(source)
752
+ _context.assets[source].sri
698
753
  end
699
754
 
700
755
  # @since 2.1.0
701
756
  # @api private
702
757
  def _absolute_url?(source)
703
- ABSOLUTE_URL_MATCHER.match(source)
758
+ ABSOLUTE_URL_MATCHER.match?(source.respond_to?(:url) ? source.url : source)
704
759
  end
705
760
 
706
761
  # @since 1.2.0
@@ -711,6 +766,18 @@ module Hanami
711
766
  _context.assets.crossorigin?(source)
712
767
  end
713
768
 
769
+ # @since x.x.x
770
+ # @api private
771
+ def _nonce(source, nonce_option)
772
+ if nonce_option == false
773
+ nil
774
+ elsif nonce_option == true || (nonce_option.nil? && !_absolute_url?(source))
775
+ content_security_policy_nonce
776
+ else
777
+ nonce_option
778
+ end
779
+ end
780
+
714
781
  # @since 2.1.0
715
782
  # @api private
716
783
  def _source_options(src, options, &blk)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "securerandom"
5
+ require_relative "../constants"
6
+
7
+ module Hanami
8
+ module Middleware
9
+ # Generates a random per request nonce value like `mSMnSwfVZVe+LyQy1SPCGw==`, stores it as
10
+ # `"hanami.content_security_policy_nonce"` in the Rack environment, and replaces all occurrences
11
+ # of `'nonce'` in the `Content-Security-Policy header with the actual nonce value for the
12
+ # request, e.g. `'nonce-mSMnSwfVZVe+LyQy1SPCGw=='`.
13
+ #
14
+ # @see Hanami::Middleware::ContentSecurityPolicyNonce
15
+ #
16
+ # @api private
17
+ # @since x.x.x
18
+ class ContentSecurityPolicyNonce
19
+ # @api private
20
+ # @since x.x.x
21
+ def initialize(app)
22
+ @app = app
23
+ end
24
+
25
+ # @api private
26
+ # @since x.x.x
27
+ def call(env)
28
+ return @app.call(env) unless Hanami.app.config.actions.content_security_policy?
29
+
30
+ args = nonce_generator.arity == 1 ? [Rack::Request.new(env)] : []
31
+ request_nonce = nonce_generator.call(*args)
32
+
33
+ env[CONTENT_SECURITY_POLICY_NONCE_REQUEST_KEY] = request_nonce
34
+
35
+ _, headers, _ = response = @app.call(env)
36
+
37
+ headers["Content-Security-Policy"] = sub_nonce(headers["Content-Security-Policy"], request_nonce)
38
+
39
+ response
40
+ end
41
+
42
+ private
43
+
44
+ def nonce_generator
45
+ Hanami.app.config.actions.content_security_policy_nonce_generator
46
+ end
47
+
48
+ def sub_nonce(string, nonce)
49
+ string&.gsub("'nonce'", "'nonce-#{nonce}'")
50
+ end
51
+ end
52
+ end
53
+ end
data/lib/hanami/slice.rb CHANGED
@@ -729,7 +729,9 @@ module Hanami
729
729
  # @api public
730
730
  # @since 2.0.0
731
731
  def routes
732
- @routes ||= load_routes
732
+ return @routes if instance_variable_defined?(:@routes)
733
+
734
+ @routes = load_routes
733
735
  end
734
736
 
735
737
  # Returns the slice's router, if or nil if no routes are defined.
@@ -925,7 +927,7 @@ module Hanami
925
927
  # Check here for the `routes` definition only, not `router` itself, because the
926
928
  # `router` requires the slice to be prepared before it can be loaded, and at this
927
929
  # point we're still in the process of preparing.
928
- if routes
930
+ if routes?
929
931
  require_relative "providers/routes"
930
932
  register_provider(:routes, source: Providers::Routes)
931
933
  end
@@ -954,9 +956,10 @@ module Hanami
954
956
  end
955
957
 
956
958
  def prepare_autoloader
957
- # Component dirs are automatically pushed to the autoloader by dry-system's
958
- # zeitwerk plugin. This method adds other dirs that are not otherwise configured
959
- # as component dirs.
959
+ autoloader.tag = "hanami.slices.#{slice_name.to_s}"
960
+
961
+ # Component dirs are automatically pushed to the autoloader by dry-system's zeitwerk plugin.
962
+ # This method adds other dirs that are not otherwise configured as component dirs.
960
963
 
961
964
  # Everything in the slice root can be autoloaded except `config/` and `slices/`,
962
965
  # which are framework-managed directories
@@ -977,11 +980,19 @@ module Hanami
977
980
  slices.freeze
978
981
  end
979
982
 
983
+ def routes?
984
+ return false unless Hanami.bundled?("hanami-router")
985
+
986
+ return true if namespace.const_defined?(ROUTES_CLASS_NAME)
987
+
988
+ root.join("#{ROUTES_PATH}#{RB_EXT}").file?
989
+ end
990
+
980
991
  def load_routes
981
992
  return false unless Hanami.bundled?("hanami-router")
982
993
 
983
994
  if root.directory?
984
- routes_require_path = File.join(root, ROUTES_PATH)
995
+ routes_require_path = root.join(ROUTES_PATH).to_s
985
996
 
986
997
  begin
987
998
  require_relative "./routes"
@@ -1050,6 +1061,11 @@ module Hanami
1050
1061
  if config.actions.sessions.enabled?
1051
1062
  use(*config.actions.sessions.middleware)
1052
1063
  end
1064
+
1065
+ if config.actions.content_security_policy && # rubocop:disable Style/SafeNavigation
1066
+ config.actions.content_security_policy.nonce?
1067
+ use(*config.actions.content_security_policy.middleware)
1068
+ end
1053
1069
  end
1054
1070
 
1055
1071
  if Hanami.bundled?("hanami-assets") && config.assets.serve
@@ -5,7 +5,7 @@ require_relative "constants"
5
5
  module Hanami
6
6
  # @api private
7
7
  class SliceRegistrar
8
- VALID_SLICE_NAME_RE = /^[a-z][a-z0-9_]+$/
8
+ VALID_SLICE_NAME_RE = /^[a-z][a-z0-9_]*$/
9
9
  SLICE_DELIMITER = CONTAINER_KEY_DELIMITER
10
10
 
11
11
  attr_reader :parent, :slices
@@ -7,7 +7,7 @@ module Hanami
7
7
  # @api private
8
8
  module Version
9
9
  # @api public
10
- VERSION = "2.2.0"
10
+ VERSION = "2.3.0.beta1"
11
11
 
12
12
  # @since 0.9.0
13
13
  # @api private
data/lib/hanami.rb CHANGED
@@ -138,7 +138,15 @@ module Hanami
138
138
  end
139
139
  end
140
140
 
141
- # Returns the Hanami app environment as loaded from the `HANAMI_ENV` environment variable.
141
+ # Returns the Hanami app environment as determined from the environment.
142
+ #
143
+ # Checks the following environment variables in order:
144
+ #
145
+ # - `HANAMI_ENV`
146
+ # - `APP_ENV`
147
+ # - `RACK_ENV`
148
+ #
149
+ # Defaults to `:development` if no environment variable is set.
142
150
  #
143
151
  # @example
144
152
  # Hanami.env # => :development
@@ -148,7 +156,7 @@ module Hanami
148
156
  # @api public
149
157
  # @since 2.0.0
150
158
  def self.env(e: ENV)
151
- e.fetch("HANAMI_ENV") { e.fetch("RACK_ENV", "development") }.to_sym
159
+ (e["HANAMI_ENV"] || e["APP_ENV"] || e["RACK_ENV"] || :development).to_sym
152
160
  end
153
161
 
154
162
  # Returns true if {.env} matches any of the given names
@@ -123,7 +123,6 @@ RSpec.describe "Cross-slice assets via helpers", :app_integration do
123
123
  compile_assets!
124
124
 
125
125
  output = Admin::Slice["views.posts.show"].call.to_s
126
-
127
126
  expect(output).to match(%r{<link href="/assets/app-[A-Z0-9]{8}.css" type="text/css" rel="stylesheet">})
128
127
  expect(output).to match(%r{<script src="/assets/app-[A-Z0-9]{8}.js" type="text/javascript"></script>})
129
128
  end
@@ -62,7 +62,7 @@ RSpec.describe "Serve Static Assets", :app_integration do
62
62
  get "/assets/../../config/app.rb"
63
63
 
64
64
  expect(last_response.status).to eq(404)
65
- expect(last_response.body).to match(/Not Found/i)
65
+ expect(last_response.body).to match("Hanami::Router::NotFoundError")
66
66
  end
67
67
  end
68
68
 
@@ -70,11 +70,13 @@ RSpec.describe "App autoloader", :app_integration do
70
70
  expect(NonApp::Thing).to be
71
71
 
72
72
  expect(TestApp::NBAJam::GetThatOuttaHere).to be
73
+ expect(TestApp::App.autoloader.tag).to eq("hanami.app.test_app")
73
74
 
74
75
  expect(Admin::Slice["operations.create_game"]).to be_an_instance_of(Admin::Operations::CreateGame)
75
76
  expect(Admin::Slice["operations.create_game"].call).to be_an_instance_of(Admin::Entities::Game)
76
77
 
77
78
  expect(Admin::Entities::Quarter).to be
79
+ expect(Admin::Slice.autoloader.tag).to eq("hanami.slices.admin")
78
80
  end
79
81
  end
80
82
  end
@@ -10,7 +10,7 @@ RSpec.describe "DB", :app_integration do
10
10
  ENV.replace(@env)
11
11
  end
12
12
 
13
- it "sets up ROM and reigsters relations" do
13
+ it "sets up ROM and registers relations" do
14
14
  with_tmp_directory(Dir.mktmpdir) do
15
15
  write "config/app.rb", <<~RUBY
16
16
  require "hanami"
@@ -235,4 +235,67 @@ RSpec.describe "DB / Logging", :app_integration do
235
235
  end
236
236
  end
237
237
  end
238
+
239
+ describe "in production" do
240
+ it "logs SQL queries" do
241
+ with_tmp_directory(Dir.mktmpdir) do
242
+ write "config/app.rb", <<~RUBY
243
+ require "hanami"
244
+
245
+ module TestApp
246
+ class App < Hanami::App
247
+ end
248
+ end
249
+ RUBY
250
+
251
+ write "app/relations/posts.rb", <<~RUBY
252
+ module TestApp
253
+ module Relations
254
+ class Posts < Hanami::DB::Relation
255
+ schema :posts, infer: true
256
+ end
257
+ end
258
+ end
259
+ RUBY
260
+
261
+ ENV["DATABASE_URL"] = "sqlite::memory"
262
+ ENV["HANAMI_ENV"] = "production"
263
+
264
+ require "hanami/setup"
265
+
266
+ logger_stream = StringIO.new
267
+ Hanami.app.config.logger.stream = logger_stream
268
+
269
+ require "hanami/prepare"
270
+
271
+ Hanami.app.prepare :db
272
+
273
+ # Manually run a migration and add a test record
274
+ gateway = Hanami.app["db.gateway"]
275
+ migration = gateway.migration do
276
+ change do
277
+ create_table :posts do
278
+ primary_key :id
279
+ column :title, :text, null: false
280
+ end
281
+
282
+ create_table :authors do
283
+ primary_key :id
284
+ end
285
+ end
286
+ end
287
+ migration.apply(gateway, :up)
288
+ gateway.connection.execute("INSERT INTO posts (title) VALUES ('Together breakfast')")
289
+
290
+ relation = Hanami.app["relations.posts"]
291
+ expect(relation.select(:title).to_a).to eq [{:title=>"Together breakfast"}]
292
+
293
+ logger_stream.rewind
294
+ log_lines = logger_stream.read.split("\n")
295
+
296
+ expect(log_lines.length).to eq 1
297
+ expect(log_lines.first).to match /Loaded :sqlite in \d+ms SELECT `posts`.`title` FROM `posts` ORDER BY `posts`.`id`/
298
+ end
299
+ end
300
+ end
238
301
  end
@@ -114,6 +114,13 @@ RSpec.describe "DB / Repo", :app_integration do
114
114
  end
115
115
  RUBY
116
116
 
117
+ write "app/repo.rb", <<~RUBY
118
+ module TestApp
119
+ class Repo < Hanami::DB::Repo
120
+ end
121
+ end
122
+ RUBY
123
+
117
124
  ENV["DATABASE_URL"] = "sqlite::memory"
118
125
 
119
126
  write "slices/admin/db/struct.rb", <<~RUBY
@@ -137,7 +144,7 @@ RSpec.describe "DB / Repo", :app_integration do
137
144
 
138
145
  write "slices/admin/repo.rb", <<~RUBY
139
146
  module Admin
140
- class Repo < Hanami::DB::Repo
147
+ class Repo < TestApp::Repo
141
148
  end
142
149
  end
143
150
  RUBY
@@ -172,7 +179,7 @@ RSpec.describe "DB / Repo", :app_integration do
172
179
 
173
180
  write "slices/main/repo.rb", <<~RUBY
174
181
  module Main
175
- class Repo < Hanami::DB::Repo
182
+ class Repo < TestApp::Repo
176
183
  end
177
184
  end
178
185
  RUBY
@@ -186,6 +193,15 @@ RSpec.describe "DB / Repo", :app_integration do
186
193
  end
187
194
  RUBY
188
195
 
196
+ write "slices/main/repositories/post_repository.rb", <<~RUBY
197
+ module Main
198
+ module Repositories
199
+ class PostRepository < Repo
200
+ end
201
+ end
202
+ end
203
+ RUBY
204
+
189
205
  require "hanami/prepare"
190
206
 
191
207
  Admin::Slice.prepare :db
@@ -204,12 +220,81 @@ RSpec.describe "DB / Repo", :app_integration do
204
220
  migration.apply(gateway, :up)
205
221
  gateway.connection.execute("INSERT INTO posts (title) VALUES ('Together breakfast')")
206
222
 
223
+ expect(Admin::Slice["repos.post_repo"].root).to eql Admin::Slice["relations.posts"]
207
224
  expect(Admin::Slice["repos.post_repo"].posts).to eql Admin::Slice["relations.posts"]
208
225
  expect(Admin::Slice["repos.post_repo"].posts.by_pk(1).one!.class).to be < Admin::Structs::Post
209
226
 
210
227
  expect(Main::Slice["repos.post_repo"].posts).to eql Main::Slice["relations.posts"]
228
+ expect(Main::Slice["repositories.post_repository"].posts).to eql Main::Slice["relations.posts"]
211
229
  # Slice struct namespace used even when no concrete struct classes are defined
212
230
  expect(Main::Slice["repos.post_repo"].posts.by_pk(1).one!.class).to be < Main::Structs::Post
213
231
  end
214
232
  end
233
+
234
+ specify "repos resolve registered container dependencies with Deps[]" do
235
+ with_tmp_directory(Dir.mktmpdir) do
236
+ write "config/app.rb", <<~RUBY
237
+ require "hanami"
238
+
239
+ module TestApp
240
+ class App < Hanami::App
241
+ Hanami.app.register("dependency") { -> { "Hello dependency!" } }
242
+ end
243
+ end
244
+ RUBY
245
+
246
+ write "app/repo.rb", <<~RUBY
247
+ module TestApp
248
+ class Repo < Hanami::DB::Repo
249
+ end
250
+ end
251
+ RUBY
252
+
253
+ write "app/relations/posts.rb", <<~RUBY
254
+ module TestApp
255
+ module Relations
256
+ class Posts < Hanami::DB::Relation
257
+ schema :posts, infer: true
258
+ end
259
+ end
260
+ end
261
+ RUBY
262
+
263
+ write "app/repos/post_repo.rb", <<~RUBY
264
+ module TestApp
265
+ module Repos
266
+ class PostRepo < Repo
267
+ include Deps["dependency"]
268
+
269
+ def test
270
+ dependency.call
271
+ end
272
+
273
+ end
274
+ end
275
+ end
276
+ RUBY
277
+
278
+ ENV["DATABASE_URL"] = "sqlite::memory"
279
+
280
+ require "hanami/prepare"
281
+
282
+ Hanami.app.prepare :db
283
+
284
+ # Manually run a migration and add a test record
285
+ gateway = Hanami.app["db.gateway"]
286
+ migration = gateway.migration do
287
+ change do
288
+ create_table :posts do
289
+ primary_key :id
290
+ column :title, :text, null: false
291
+ end
292
+ end
293
+ end
294
+ migration.apply(gateway, :up)
295
+
296
+ repo = Hanami.app["repos.post_repo"]
297
+ expect(repo.test).to eq "Hello dependency!"
298
+ end
299
+ end
215
300
  end
@@ -73,7 +73,12 @@ RSpec.describe "Logging / Exception logging", :app_integration do
73
73
  expect(logs.lines.length).to be > 10
74
74
  expect(logs).to match %r{GET 500 \d+(µs|ms) 127.0.0.1 /}
75
75
  expect(logs).to include("unhandled (TestApp::Actions::Test::UnhandledError)")
76
- expect(logs).to include("app/actions/test.rb:7:in `handle'")
76
+
77
+ if RUBY_VERSION < "3.4"
78
+ expect(logs).to include("app/actions/test.rb:7:in `handle'")
79
+ else
80
+ expect(logs).to include("app/actions/test.rb:7:in 'TestApp::Actions::Test#handle'")
81
+ end
77
82
  end
78
83
 
79
84
  it "re-raises the exception" do