brut 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- 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
|
-
|
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
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
data/lib/brut/front_end/page.rb
CHANGED
@@ -1,42 +1,77 @@
|
|
1
|
-
# A page
|
2
|
-
#
|
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
|
-
#
|
23
|
-
#
|
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
|
-
|
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.
|
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
|
-
#
|
42
|
-
|
43
|
-
|
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
|
-
|
47
|
-
|
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
|
-
|
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(
|
3
|
+
def after(session:,request_context:)
|
3
4
|
flash = request_context[:flash]
|
4
5
|
flash.age!
|
5
|
-
|
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
|
-
|
8
|
-
|
9
|
-
|
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(
|
6
|
+
def before(session:,env:)
|
3
7
|
http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_header(env["HTTP_ACCEPT_LANGUAGE"])
|
4
|
-
|
5
|
-
|
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
|
-
|
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
|
-
|
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(
|
3
|
-
flash =
|
4
|
-
|
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:
|
10
|
+
Brut::FrontEnd::RequestContext.new(env:,session:session,flash:,xhr: request.xhr?,body: request.body)
|
8
11
|
)
|
9
12
|
continue
|
10
13
|
end
|