brut 0.0.1 → 0.0.2
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.
- 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
|