brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
@@ -1,23 +1,38 @@
1
+ # Reloads the app without requiring a restart. This should only be used in development. Every single request will trigger this.
1
2
  class Brut::FrontEnd::Middlewares::ReloadApp < Brut::FrontEnd::Middleware
2
3
  LOCK = Concurrent::ReadWriteLock.new
3
4
  def initialize(app)
4
5
  @app = app
5
6
  end
6
7
  def call(env)
7
- Brut.container.instrumentation.instrument(Brut::Instrumentation::Event.new(category: "middleware", subcategory: self.class.name, name: "call")) do
8
+ path = env["PATH_INFO"].to_s
9
+ dir = if path[0] == "/"
10
+ path.split(/\//)[1]
11
+ else
12
+ path.split(/\//)[0]
13
+ end
14
+ reload = !["static","js","css","__brut"].include?(dir)
15
+ if reload
8
16
  # We can only have one thread reloading stuff at a time, per process.
9
17
  # The ReadWriteLock achieves this.
10
18
  #
11
19
  # Here, if any thread is serving a request, THIS thread will wait here.
12
20
  # Once no other thread is serving a request, the write lock is acquired and a reload happens.
13
- LOCK.with_write_lock do
14
- begin
15
- Brut.container.zeitwerk_loader.reload
16
- Brut.container.routing.reload
17
- Brut.container.asset_path_resolver.reload
18
- ::I18n.reload!
19
- rescue => ex
20
- SemanticLogger[self.class].warn("Reload failed - your browser may not show you the latest code: #{ex.message}")
21
+ Brut.container.instrumentation.span(self.class.name) do |span|
22
+ LOCK.with_write_lock do
23
+ span.add_event("lock acquired")
24
+ begin
25
+ Brut.container.zeitwerk_loader.reload
26
+ span.add_event("Zeitwerk reloaded")
27
+ Brut.container.routing.reload
28
+ span.add_event("Routing reloaded")
29
+ Brut.container.asset_path_resolver.reload
30
+ span.add_event("Asset Path Resolver reloaded")
31
+ ::I18n.reload!
32
+ span.add_event("I18n reloaded")
33
+ rescue => ex
34
+ SemanticLogger[self.class].warn("Reload failed - your browser may not show you the latest code: #{ex.message}\n#{ex.backtrace}")
35
+ end
21
36
  end
22
37
  end
23
38
  # If another thread has a write lock, we wait here so that the reload can complete before serving
@@ -26,6 +41,9 @@ class Brut::FrontEnd::Middlewares::ReloadApp < Brut::FrontEnd::Middleware
26
41
  LOCK.with_read_lock do
27
42
  @app.call(env)
28
43
  end
44
+ else
45
+ Brut.container.instrumentation.add_event("Not reloading")
46
+ @app.call(env)
29
47
  end
30
48
  end
31
49
  end
@@ -1,42 +1,77 @@
1
- # A page is a component that has a layout and thus is intended to be
2
- # an entire web page, not just a fragment.
1
+ # A page backs a web page. A page renders everything in the browser window. Technically, it is exactly like a component except that
2
+ # it can have a layout.
3
+ #
4
+ # When subclassing this to create a page, your initializer's signature will determine what data
5
+ # is required for your page to work. It can be anything, just keep in mind that any component your page uses may
6
+ # require additional data.
7
+ #
8
+ # If your page does not override {#render} (which, generally, it won't), an ERB file is expected to exist alongside it in the
9
+ # app. For example, if you have a page named `Auth::LoginPage`, it would expected to be in
10
+ # `app/src/front_end/pages/auth/login_page.rb`. Thus, Brut will also expect
11
+ # `app/src/front_end/pages/auth/login_page.html.erb` to exist as well. That ERB file is used with an instance of your
12
+ # pages's class to render the page's HTML.
13
+ #
14
+ # @see Brut::FrontEnd::Component
3
15
  class Brut::FrontEnd::Page < Brut::FrontEnd::Component
4
16
  include Brut::FrontEnd::HandlingResults
5
17
  using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
6
18
 
19
+ # Returns the name of the layout for this page. This string is used to find an ERB file in `app/src/front_end/layouts`. Every page
20
+ # must have a layout. If you wish to render a page with no layout, create an empty layout in your app and use that.
21
+ #
22
+ # @return [String] The name of the layout. May not be `nil`.
7
23
  def layout = "default"
8
24
 
25
+ # Called after the page is created, but before {#render} is called. This allows you to do any pre-flight checks and potentially
26
+ # redirect the user or produce an error.
27
+ #
28
+ # @return [URI|Brut::FrontEnd::HttpStatus|Object] If you return a `URI` (mostly likely by returning the result of calling {Brut::FrontEnd::HandlingResults#redirect_to}), the user is redirected and {#render} is never called. If you return a {Brut::FrontEnd::HttpStatus} (mostly likely by returning the result of calling {Brut::FrontEnd::HandlingResults#http_status}), {#render} is skipped and that status is returned with no content. If anything else is returned, {#render} is called as normal.
9
29
  def before_render = nil
10
30
 
31
+ # @!visibility private
11
32
  def handle!
12
33
  case before_render
13
34
  in URI => uri
35
+ Brut.container.instrumentation.add_event("before_render got a URI", uri: uri)
14
36
  uri
15
37
  in Brut::FrontEnd::HttpStatus => http_status
38
+ Brut.container.instrumentation.add_event("before_render got status", http_status: http_status)
16
39
  http_status
17
40
  else
18
41
  render
19
42
  end
20
43
  end
21
44
 
22
- # Overrides component's render to add the concept of a layout.
23
- # A layout is an HTML/ERB file that will contain this page's contents.
45
+ # The core method of a page, which overrides {Brut::FrontEnd::Component#render}. This is expected to return
46
+ # a string to be sent as a response to an HTTP request. Generally, you should not call this method as it is
47
+ # called by Brut when your page is requested.
48
+ #
49
+ # Also, generally don't override this unles you need to do something unusual. Overriding this will completely bypass the layout
50
+ # system and skip all ERB processing. Unlike {Brut::FrontEnd::Component#render}, overriding this method does not provide access to injected data from the request context.
51
+ #
52
+ # @return [Brut::FrontEnd::Templates::HTMLSafeString] string containing the page's full HTML.
24
53
  def render
25
- Brut.container.layout_locator.locate(self.layout).
26
- then { |layout_erb_file| Brut::FrontEnd::Template.new(layout_erb_file)
27
- } => layout_template
54
+ layout_template = Brut.container.layout_locator.locate(self.layout).
55
+ then { |layout_erb_file| Brut::FrontEnd::Template.new(layout_erb_file) }
28
56
 
29
- Brut.container.page_locator.locate(self.template_name).
30
- then { |erb_file| Brut::FrontEnd::Template.new(erb_file)
31
- } => template
57
+ template = Brut.container.page_locator.locate(self.template_name).
58
+ then { |erb_file| Brut::FrontEnd::Template.new(erb_file) }
32
59
 
60
+ Brut.container.instrumentation.add_event("templates found", layout: layout_template.template_file_path, page: template.template_file_path)
61
+
62
+ page = template.render_template(self).html_safe!
33
63
  layout_template.render_template(self) do
34
- template.render_template(self).html_safe!
64
+ page
35
65
  end
36
66
  end
37
67
 
68
+ # @return [String] name of this page for use in debugging or for whatever reason you may want to dynamically refer to the page's name. The default value is the class name.
38
69
  def self.page_name = self.name
70
+
71
+ # Convienience method for {.page_name}.
39
72
  def page_name = self.class.page_name
73
+
74
+ # @!visibility private
40
75
  def component_name = raise Brut::Framework::Errors::Bug,"#{self.class} is not a component"
41
76
 
42
77
  private
@@ -45,3 +80,7 @@ private
45
80
 
46
81
  end
47
82
 
83
+ # Holds pages included with the Brut framework
84
+ module Brut::FrontEnd::Pages
85
+ autoload(:MissingPage,"brut/front_end/pages/missing_page.rb")
86
+ end
@@ -0,0 +1,17 @@
1
+ <main class="missing-page">
2
+ <h1>Route Not Implemented</h1>
3
+ <h2>Cannot Find <code><%= class_name %></code></h2>
4
+ <p>
5
+ You defined <code><%= path_template %></code> in your app, however the class(es) that impelment it could not be found.
6
+ </p>
7
+ <% if scaffold_command %>
8
+ <p>
9
+ To create the expected <%= types_of_files_created %>, run this command:
10
+ </p>
11
+ <pre><code>bin/scaffold <%= scaffold_command %> <%= class_name %></code></pre>
12
+ <% else %>
13
+ <p>
14
+ Something is wrong - Brut cannot figure out what sort of route you are trying to create. This is probably a bug!
15
+ </p>
16
+ <% end %>
17
+ </main>
@@ -0,0 +1,36 @@
1
+ # Used in development when a route has been mapped, but no class exists for the page. This
2
+ # renders a hopefully helpful message in the browser to allow the developer to know what
3
+ # next steps to take.
4
+ class Brut::FrontEnd::Pages::MissingPage < Brut::FrontEnd::Page
5
+
6
+ attr_reader :class_name,
7
+ :path_template,
8
+ :class_file,
9
+ :scaffold_command,
10
+ :types_of_files_created
11
+
12
+ def initialize(route:)
13
+ @class_name = route.exception.class_name_path.join("::")
14
+ @path_template = route.path_template
15
+ parts = route.exception.class_name_path.map { |part|
16
+ RichString.new(part).underscorized.to_s
17
+ }
18
+ last_part = parts[-1]
19
+ parts[-1] = last_part + ".rb"
20
+ if route.class == Brut::FrontEnd::Routing::MissingForm
21
+ @scaffold_command = "form"
22
+ @types_of_files_created = "form class, handler class, and test"
23
+ elsif route.class == Brut::FrontEnd::Routing::MissingPage
24
+ @scaffold_command = "page"
25
+ @types_of_files_created = "page class, HTML template, and test"
26
+ elsif route.class == Brut::FrontEnd::Routing::MissingPath
27
+ @scaffold_command = "handler"
28
+ @types_of_files_created = "handler class, and test"
29
+ else
30
+ nil
31
+ end
32
+ end
33
+
34
+ def layout = "_internal"
35
+ def template_name = "_missing_page"
36
+ end
@@ -1,4 +1,20 @@
1
+ # Container for request-specific information that serves as the source of what can be automaticall passed to various methods by Brut.
2
+ #
3
+ # The intention for this class is to provide access to the 80% of stuff needed by most requests, to alleviate the need to have to dig
4
+ # into `env` or the Rack request. This also allows arbitrary information to be inserted and made available later.
5
+ #
6
+ # Several methods of Brut objects take keyword arguments in their initializer or a particular method. The names of those keyword
7
+ # arguments correspond to values that are contained by this class. Thus, if you are creating, say, a {Brut::FrontEnd::Page} subclass,
8
+ # and create an initializer for it that accepts the `clock:` keyword argument, the managed instance of {Clock} will be passed into it
9
+ # when Brut creates an instance of the class.
1
10
  class Brut::FrontEnd::RequestContext
11
+ # Create a new RequestContext based on some of the information provided by Rack
12
+ #
13
+ # @param [Hash] env the Rack `env` object, as available to any middleware
14
+ # @param [Brut::FrontEnd::Session] session the current session, noting that this is the Brut (or your app) session class and not the Rack session.
15
+ # @param [Brut::FrontEnd::Flash] flash the current flash
16
+ # @param [true|false] xhr true if this is an XHR request.
17
+ # @param [Object] body the `request.body` as provided by Rack
2
18
  def initialize(env:,session:,flash:,xhr:,body:)
3
19
  @hash = {
4
20
  env:,
@@ -7,16 +23,25 @@ class Brut::FrontEnd::RequestContext
7
23
  xhr:,
8
24
  body:,
9
25
  csrf_token: Rack::Protection::AuthenticityToken.token(env["rack.session"]),
10
- clock: Clock.new(session.timezone_from_browser),
26
+ clock: Clock.new(session.timezone),
11
27
  }
12
28
  end
13
29
 
14
30
 
31
+ # Set an arbitrary value that can be injected later
32
+ # @param [String|Symbol] key the name of the value. This is converted to a symbol.
33
+ # @param [Object] value the value to map. Should not be nil.
15
34
  def []=(key,value)
16
35
  key = key.to_sym
17
36
  @hash[key] = value
18
37
  end
19
38
 
39
+ # Access the given value, raising an exception if it has not been set or if it's nil.
40
+ # @param [String|Symbol] key the value to fetch.
41
+ #
42
+ # @return [Object] the mapped value
43
+ #
44
+ # @raise [ArgumentError] if `key` was never mapped or maps to `nil`.
20
45
  def fetch(key)
21
46
  if self.key?(key)
22
47
  value = self[key]
@@ -30,26 +55,101 @@ class Brut::FrontEnd::RequestContext
30
55
  end
31
56
  end
32
57
 
58
+ # Access a given value, returning `nil` if it's not mapped or is `nil`
59
+ # @param [String|Symbol] key the value to get
60
+ # @return [Object] the mapped value
33
61
  def [](key)
34
62
  @hash[key.to_sym]
35
63
  end
36
64
 
65
+ # Check if a given value has been mapped.
66
+ # @param [String|Symbol] key the value to check
67
+ # @return [true|false] if the value is mapped. Note that if `nil` was injected, this method returns `true`.
37
68
  def key?(key)
38
69
  @hash.key?(key.to_sym)
39
70
  end
40
71
 
41
- # Returns a hash suitable to passing into this class' constructor.
42
- def as_constructor_args(klass, request_params:)
43
- args_for_method(method: klass.instance_method(:initialize), request_params:, form: nil)
72
+ # Based on `klass`' constructor, returns a Hash that maps all keywords it requires to the values stored in this
73
+ # `RequestContext`. It is assumed that `request_params:` contains the query parameters so they can be injected.
74
+ # The {Brut::FrontEnd::Routing::Route} can also be injected to pass in.
75
+ #
76
+ # @example
77
+ # class SomeClass
78
+ # def initialize(flash:,clock:,date:)
79
+ # # ...
80
+ # end
81
+ # end
82
+ #
83
+ # hash = request_context.as_constructor_args(
84
+ # SomeClass,
85
+ # request_params: { date: "2024-11-11" }
86
+ # )
87
+ #
88
+ # # hash contains:
89
+ # # {
90
+ # # flash: «Flash used to create the RequestContext»,
91
+ # # clock: «Clock used to create the RequestContext»,
92
+ # # date: "2024-11-11",
93
+ # # }
94
+ #
95
+ # object = SomeClass.new(**hash)
96
+ #
97
+ # @param [Class] klass a class that is to be instantiated entirely by the contents of this `RequestContext`.
98
+ # @param [Hash] request_params Query string parameters provided by Rack.
99
+ # @param [Brut::FrontEnd::Routing::Route] route the route that triggered the request.
100
+ # @return [Hash] can be splatted to keyword arguments and passed to the constructor of `klass`
101
+ #
102
+ # @raise [ArgumentError] if the constructor has any non-keyword arguments, or if any required keyword argument is
103
+ # not present in this `RequestContext`.
104
+ def as_constructor_args(klass, request_params:, route:nil)
105
+ args_for_method(method: klass.instance_method(:initialize), request_params:, form: nil, route:)
44
106
  end
45
107
 
46
- def as_method_args(object, method_name, request_params:,form:)
47
- args_for_method(method: object.method(method_name), request_params:, form:)
108
+ # Based on `object`' method, returns a Hash that maps all keywords it requires to the values stored in this
109
+ # `RequestContext`. It is assumed that `request_params:` contains the query parameters so they can be injected.
110
+ # It is also assumed that `form:` is the {Brut::FrontEnd::Form} that is provided as part of the request.
111
+ # The {Brut::FrontEnd::Routing::Route} can also be injected to pass in.
112
+ #
113
+ # @example
114
+ # class SomeClass
115
+ # def doit(flash:,clock:,date:)
116
+ # # ...
117
+ # end
118
+ # end
119
+ #
120
+ # object = SomeClass.new
121
+ #
122
+ # hash = request_context.as_method_args(
123
+ # object,
124
+ # :doit,
125
+ # request_params: { date: "2024-11-11" }
126
+ # )
127
+ #
128
+ # # hash contains:
129
+ # # {
130
+ # # flash: «Flash used to create the RequestContext»,
131
+ # # clock: «Clock used to create the RequestContext»,
132
+ # # date: "2024-11-11",
133
+ # # }
134
+ #
135
+ # result = object.doit(**hash)
136
+ #
137
+ # @param [Class] object an object whose method is to be called that requires some of the contents of this `RequestContext`.
138
+ # @param [Symbol] method_name name of the method that will be called.
139
+ # @param [Hash] request_params Query string parameters provided by Rack. Note that any parameter whose value is the empty string will be coerced to `nil`.
140
+ # @param [Brut::FrontEnd::Routing::Route] route the route that triggered the request.
141
+ # @param [Brut::FrontEnd::Form] form the form that was submitted with this request. May be `nil`.
142
+ # @return [Hash] can be splatted to keyword arguments and passed to the constructor of `klass`
143
+ #
144
+ # @raise [ArgumentError] if the method has any non-keyword arguments, or if any required keyword argument is
145
+ # not present in this `RequestContext`.
146
+ def as_method_args(object, method_name, request_params:,form:,route:nil)
147
+ args_for_method(method: object.method(method_name), request_params:, form:,route:)
48
148
  end
49
149
 
50
150
  private
51
151
 
52
- def args_for_method(method:, request_params:, form: )
152
+ def args_for_method(method:, request_params:, form:,route:)
53
153
  args = {}
54
154
  method.parameters.each do |(type,name)|
55
155
 
@@ -62,10 +162,19 @@ private
62
162
 
63
163
  if self.key?(name)
64
164
  args[name] = self[name]
165
+ elsif name.to_s =~ /^http_[^_]+/
166
+ header_value = self[:env][name.to_s.upcase]
167
+ if header_value
168
+ args[name] = header_value
169
+ elsif type == :keyreq
170
+ args[name] = nil
171
+ end
65
172
  elsif !form.nil? && name == :form
66
173
  args[name] = form
174
+ elsif !route.nil? && name == :route
175
+ args[name] = route
67
176
  elsif !request_params.nil? && (request_params[name.to_s] || request_params[name.to_sym])
68
- args[name] = request_params[name.to_s] || request_params[name.to_sym]
177
+ args[name] = RichString.new(request_params[name.to_s] || request_params[name.to_sym]).to_s_or_nil
69
178
  elsif type == :keyreq
70
179
  request_params_message = if request_params.nil?
71
180
  "no request params provied"
@@ -1,7 +1,49 @@
1
1
  module Brut::FrontEnd
2
+ # Base class for all route hooks. A route hook must implement either `before` or `after` and can be used via
3
+ # {Brut::Framework::App.before} or {Brut::Framework::App.after}.
4
+ #
5
+ # A route hook differs from Middleware in to ways:
6
+ #
7
+ # * A route hook has a rich structured return type that is more expressive that Rack's array, however less powerful.
8
+ # * A route hook can be injected with session and request information via {Brut::FrontEnd::RequestContext}. This allows your route
9
+ # hooks to easily access information like the currently-logged-in user, session, flash, or query string parameters.
10
+ #
11
+ # Note that while a route hook can be used as both a before and an after, state will not be shared.
2
12
  class RouteHook
3
13
  include Brut::FrontEnd::HandlingResults
4
- # Return this to continue the hook
14
+ include Brut::Framework::Errors
15
+
16
+ # Subclasses should implement this if they intend to be used as before hooks. The method parameters that the subclass uses will
17
+ # determine what information is avaiable.
18
+ #
19
+ # The return type determines what happens:
20
+ #
21
+ # * `URI` - the browser will be redirected to this URI
22
+ # * `Brut::FrontEnd::HttpStatus` - the request will be terminated with this status
23
+ # * `false` - the request is terminated with a 500
24
+ # * `true` or `nil` - the request will continue to the next hook or to the route handler. Use {#continue} if this is what you want to happen
25
+ #
26
+ # @return [URI|Brut::FrontEnd::HttpStatus|false|true|nil]
27
+ def before
28
+ abstract_method!
29
+ end
30
+
31
+ # Subclasses should implement this if they intend to be used as after hooks. The method parameters that the subclass uses will
32
+ # determine what information is avaiable.
33
+ #
34
+ # The return type determines what happens:
35
+ #
36
+ # * `URI` - the browser will be redirected to this URI
37
+ # * `Brut::FrontEnd::HttpStatus` - the request will be terminated with this status
38
+ # * `false` - the request is terminated with a 500
39
+ # * `true` or `nil` - the request will continue to the next hook or to the browser. Use {#continue} if this is what you want to happen
40
+ #
41
+ # @return [URI|Brut::FrontEnd::HttpStatus|false|true|nil]
42
+ def after
43
+ abstract_method!
44
+ end
45
+
46
+ # Return this to continue the hook. This is preferred over `true` or `nil` as it communicates the intent of what should happen
5
47
  def continue = true
6
48
  end
7
49
 
@@ -1,8 +1,9 @@
1
+ # Ages the flash every time there is a request
1
2
  class Brut::FrontEnd::RouteHooks::AgeFlash < Brut::FrontEnd::RouteHook
2
- def after(app_session:,request_context:)
3
+ def after(session:,request_context:)
3
4
  flash = request_context[:flash]
4
5
  flash.age!
5
- app_session.flash = flash
6
+ session.flash = flash
6
7
  continue
7
8
  end
8
9
  end
@@ -1,9 +1,17 @@
1
+ # Sets content security policy headers that forbid inline scripts, but allow inline styles.
2
+ # This is intended to be used in development to allow easier UI design work to happen in the browser
3
+ # by the temporary use of inline styles.
4
+ #
5
+ # @see Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts
6
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
1
7
  class Brut::FrontEnd::RouteHooks::CSPNoInlineScripts < Brut::FrontEnd::RouteHook
2
8
  def after(response:)
3
9
  response.headers["Content-Security-Policy"] = header_value
4
10
  continue
5
11
  end
6
12
 
13
+ private
14
+
7
15
  def header_value
8
16
  [
9
17
  "default-src 'self'",
@@ -1,19 +1,16 @@
1
+ # Sets content security policy headers that forbid inline scripts and inline styles.
2
+ #
3
+ # @see Brut::FrontEnd::RouteHooks::CSPNoInlineScripts
4
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
1
5
  class Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts < Brut::FrontEnd::RouteHook
2
6
  def after(response:)
3
7
  response.headers["Content-Security-Policy"] = header_value
4
8
  continue
5
9
  end
6
10
 
7
- def header_value
8
- [
9
- "default-src 'self'",
10
- "script-src-elem 'self'",
11
- "script-src-attr 'none'",
12
- "style-src-elem 'self'",
13
- "style-src-attr 'none'",
14
- ].join("; ")
15
- end
16
-
11
+ # Sets content security policy headers that only report the use inline scripts and inline styles, but do allow them.
12
+ # This is useful for existing apps where you want to migrate to a more secure policy, but cannot.
13
+ # @see Brut::FrontEnd::Handlers::CspReportingHandler
17
14
  class ReportOnly < Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts
18
15
  def after(response:,request:)
19
16
  csp_reporting_path = uri(Brut::FrontEnd::Handlers::CspReportingHandler.routing,request:)
@@ -28,6 +25,17 @@ class Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts < Brut::FrontEnd::R
28
25
 
29
26
  private
30
27
 
28
+ def header_value
29
+ [
30
+ "default-src 'self'",
31
+ "script-src-elem 'self'",
32
+ "script-src-attr 'none'",
33
+ "style-src-elem 'self'",
34
+ "style-src-attr 'none'",
35
+ ].join("; ")
36
+ end
37
+
38
+
31
39
  def uri(path,request:)
32
40
  # Adapted from Sinatra's innards
33
41
  host = "http#{'s' if request.secure?}://"
@@ -1,11 +1,16 @@
1
+ # Detects the user's locale from the `Accept-Language` header and, if one of the locales has been set up in this app, configured
2
+ # Ruby's `I18n` to use it. This will also store the value in the session via {Brut::FrontEnd::Session#http_accept_language=}.
3
+ #
4
+ # @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
1
5
  class Brut::FrontEnd::RouteHooks::LocaleDetection < Brut::FrontEnd::RouteHook
2
- def before(app_session:,env:)
6
+ def before(session:,env:)
3
7
  http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_header(env["HTTP_ACCEPT_LANGUAGE"])
4
- if !app_session.http_accept_language.known?
5
- app_session.http_accept_language = http_accept_language
8
+ Brut.container.instrumentation.add_attributes(http_accept_language:)
9
+ if !session.http_accept_language.known?
10
+ session.http_accept_language = http_accept_language
6
11
  end
7
12
  best_locale = nil
8
- app_session.http_accept_language.weighted_locales.each do |weighted_locale|
13
+ session.http_accept_language.weighted_locales.each do |weighted_locale|
9
14
  if ::I18n.available_locales.include?(weighted_locale.locale.to_sym)
10
15
  best_locale = weighted_locale.locale.to_sym
11
16
  break
@@ -15,9 +20,10 @@ class Brut::FrontEnd::RouteHooks::LocaleDetection < Brut::FrontEnd::RouteHook
15
20
  end
16
21
  end
17
22
  if best_locale
23
+ Brut.container.instrumentation.add_attributes(best_locale:)
18
24
  ::I18n.locale = best_locale
19
25
  else
20
- SemanticLogger["Brut"].warn("None of the user's locales are available: #{app_session.http_accept_language}")
26
+ Brut.container.instrumentation.add_attributes(best_locale: false)
21
27
  end
22
28
  continue
23
29
  end
@@ -1,10 +1,13 @@
1
+ # Sets up the {Brut::FrontEnd::RequestContext} based on the contents of the session.
2
+ # This is so that downstream handlers and hooks can have access to richer data than the hashes
3
+ # and strings provided by Rack.
1
4
  class Brut::FrontEnd::RouteHooks::SetupRequestContext < Brut::FrontEnd::RouteHook
2
- def before(app_session:,request:,env:)
3
- flash = app_session.flash
4
- app_session[:_flash] ||= flash
5
+ def before(session:,request:,env:)
6
+ flash = session.flash
7
+ session[:_flash] ||= flash
5
8
  Thread.current.thread_variable_set(
6
9
  :request_context,
7
- Brut::FrontEnd::RequestContext.new(env:,session:app_session,flash:,xhr: request.xhr?,body: request.body)
10
+ Brut::FrontEnd::RequestContext.new(env:,session:session,flash:,xhr: request.xhr?,body: request.body)
8
11
  )
9
12
  continue
10
13
  end