hanami 2.2.1 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -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/view/context.rb +10 -0
  14. data/lib/hanami/helpers/assets_helper.rb +92 -25
  15. data/lib/hanami/middleware/content_security_policy_nonce.rb +53 -0
  16. data/lib/hanami/slice.rb +22 -6
  17. data/lib/hanami/slice_registrar.rb +1 -1
  18. data/lib/hanami/version.rb +1 -1
  19. data/lib/hanami.rb +10 -2
  20. data/spec/integration/assets/cross_slice_assets_helpers_spec.rb +0 -1
  21. data/spec/integration/assets/serve_static_assets_spec.rb +1 -1
  22. data/spec/integration/container/autoloader_spec.rb +2 -0
  23. data/spec/integration/db/db_spec.rb +1 -1
  24. data/spec/integration/db/logging_spec.rb +63 -0
  25. data/spec/integration/db/repo_spec.rb +87 -2
  26. data/spec/integration/logging/exception_logging_spec.rb +6 -1
  27. data/spec/integration/rack_app/middleware_spec.rb +4 -11
  28. data/spec/integration/view/helpers/form_helper_spec.rb +1 -1
  29. data/spec/integration/web/content_security_policy_nonce_spec.rb +251 -0
  30. data/spec/support/app_integration.rb +2 -1
  31. data/spec/unit/hanami/config/actions/content_security_policy_spec.rb +7 -0
  32. data/spec/unit/hanami/config/console_spec.rb +22 -0
  33. data/spec/unit/hanami/env_spec.rb +10 -13
  34. data/spec/unit/hanami/slice_spec.rb +18 -0
  35. data/spec/unit/hanami/version_spec.rb +1 -1
  36. data/spec/unit/hanami/web/rack_logger_spec.rb +11 -4
  37. metadata +27 -18
  38. data/spec/support/shared_examples/cli/generate/app.rb +0 -494
  39. data/spec/support/shared_examples/cli/generate/migration.rb +0 -32
  40. data/spec/support/shared_examples/cli/generate/model.rb +0 -81
  41. data/spec/support/shared_examples/cli/new.rb +0 -97
@@ -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.1"
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
@@ -507,7 +507,7 @@ RSpec.describe "Hanami web app", :app_integration do
507
507
  write "lib/test_app/middleware/api_version.rb", <<~RUBY
508
508
  module TestApp
509
509
  module Middleware
510
- class ApiVersion
510
+ class APIVersion
511
511
  def self.inspect
512
512
  "<Middleware::API::Version>"
513
513
  end
@@ -530,7 +530,7 @@ RSpec.describe "Hanami web app", :app_integration do
530
530
  write "lib/test_app/middleware/api_deprecation.rb", <<~RUBY
531
531
  module TestApp
532
532
  module Middleware
533
- class ApiDeprecation
533
+ class APIDeprecation
534
534
  def self.inspect
535
535
  "<Middleware::API::Deprecation>"
536
536
  end
@@ -578,13 +578,6 @@ RSpec.describe "Hanami web app", :app_integration do
578
578
  RUBY
579
579
 
580
580
  write "config/routes.rb", <<~RUBY
581
- require "test_app/middleware/elapsed"
582
- require "test_app/middleware/authentication"
583
- require "test_app/middleware/rate_limiter"
584
- require "test_app/middleware/api_version"
585
- require "test_app/middleware/api_deprecation"
586
- require "test_app/middleware/scope_identifier"
587
-
588
581
  module TestApp
589
582
  class Routes < Hanami::Routes
590
583
  use TestApp::Middleware::Elapsed
@@ -609,8 +602,8 @@ RSpec.describe "Hanami web app", :app_integration do
609
602
  root to: ->(*) { [200, {"Content-Length" => "3"}, ["API"]] }
610
603
 
611
604
  slice :api_v1, at: "/v1" do
612
- use TestApp::Middleware::ApiVersion
613
- use TestApp::Middleware::ApiDeprecation
605
+ use TestApp::Middleware::APIVersion
606
+ use TestApp::Middleware::APIDeprecation
614
607
  use TestApp::Middleware::ScopeIdentifier, "API-V1"
615
608
 
616
609
  root to: "home.show"
@@ -156,7 +156,7 @@ RSpec.describe "Helpers / FormHelper", :app_integration do
156
156
  module TestApp
157
157
  class App < Hanami::App
158
158
  config.logger.stream = StringIO.new
159
- config.actions.sessions = :cookie, {secret: "xyz"}
159
+ config.actions.sessions = :cookie, {secret: "wxyz"*16} # Rack demands >=64 characters
160
160
  end
161
161
  end
162
162
  RUBY