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,13 +1,31 @@
|
|
|
1
1
|
module Brut
|
|
2
2
|
module Framework
|
|
3
|
+
# Include this in your class to access some helpful methods that throw commonly-needed
|
|
4
|
+
# errors
|
|
3
5
|
module Errors
|
|
4
6
|
autoload(:Bug,"brut/framework/errors/bug")
|
|
7
|
+
autoload(:NotImplemented,"brut/framework/errors/not_implemented")
|
|
5
8
|
autoload(:NotFound,"brut/framework/errors/not_found")
|
|
9
|
+
autoload(:MissingParameter,"brut/framework/errors/missing_parameter")
|
|
6
10
|
autoload(:AbstractMethod,"brut/framework/errors/abstract_method")
|
|
11
|
+
autoload(:NoClassForPath,"brut/framework/errors/no_class_for_path")
|
|
12
|
+
# Raises {Brut::Framework::Errors::Bug}
|
|
13
|
+
#
|
|
14
|
+
# @param message Message to include in the error
|
|
15
|
+
# @raise [Brut::Framework::Errors::Bug]
|
|
7
16
|
def bug!(message=nil)
|
|
8
17
|
raise Brut::Framework::Errors::Bug,message
|
|
9
18
|
end
|
|
19
|
+
|
|
20
|
+
# Raises {Brut::Framework::Errors::AbstractMethod}
|
|
21
|
+
# @raise [Brut::Framework::Errors::AbstractMethod]
|
|
22
|
+
def abstract_method!
|
|
23
|
+
raise Brut::Framework::Errors::AbstractMethod
|
|
24
|
+
end
|
|
10
25
|
end
|
|
26
|
+
# Base class for errors thrown by Brut classes. Generally, Brut will not create its own version
|
|
27
|
+
# of an error that exists in the standard library, e.g. `ArgumentError`, so this is only a base class
|
|
28
|
+
# for Brut-specific error conditions.
|
|
11
29
|
class Error < StandardError
|
|
12
30
|
end
|
|
13
31
|
end
|
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
# Include this to enable methods to help with type checking. Generally, you should not use this
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
1
|
+
# Include this to enable methods to help with type checking. Generally, you should not use this.
|
|
2
|
+
# You should only really use this if all of the following are true:
|
|
3
|
+
#
|
|
4
|
+
# * Passing in the wrong type Would Be Bad.
|
|
5
|
+
# * The developer passing it in would not easily be able to figure out what went wrong.
|
|
5
6
|
module Brut::Framework::FussyTypeEnforcement
|
|
6
|
-
# Perform basic type checking, ideally inside a constructor when assigning ivars
|
|
7
|
+
# Perform basic type checking, ideally inside a constructor when assigning ivars. This is really intended for internal
|
|
8
|
+
# classes that will not be exposed to user input, but rather to catch programmer bugs and programmer mis-use.
|
|
7
9
|
#
|
|
8
|
-
# value
|
|
9
|
-
# type_descriptor
|
|
10
|
-
#
|
|
11
|
-
# variable_name_for_error_message
|
|
12
|
-
# required
|
|
13
|
-
#
|
|
14
|
-
# coerce
|
|
10
|
+
# @param [Object] value the value that is to be type-checked
|
|
11
|
+
# @param [Class|Array<Object>] type_descriptor a class or an array of allowed values. If a class, `value` must be `kind_of?`
|
|
12
|
+
# that class. If an array, `value` must be one of the values in the array.
|
|
13
|
+
# @param [String] variable_name_for_error_message the name of the variable begin type-checked so that error messages make sense
|
|
14
|
+
# @param [true|false] required if true, the value may not be nil. If false, nil values are allowed. Note that in this context
|
|
15
|
+
# a blank string counts as `nil`, so required strings may not be blank.
|
|
16
|
+
# @param [Symbol|false] coerce if set, this is the symbol that will be used to coerce the value before type checking.
|
|
17
|
+
# For example, if you accept a string but know it should be a number, pass in `:to_i`.
|
|
18
|
+
# @return [Object] the value, if it matches the expectations of its type
|
|
19
|
+
# @raise [ArgumentError] if the value doesn't confirm to the described type
|
|
15
20
|
def type!(value,type_descriptor,variable_name_for_error_message, required: false, coerce: false)
|
|
16
21
|
|
|
17
22
|
value_blank = value.nil? || ( value.kind_of?(String) && value.strip == "" )
|
data/lib/brut/framework/mcp.rb
CHANGED
|
@@ -6,10 +6,17 @@ require "sequel"
|
|
|
6
6
|
require "semantic_logger"
|
|
7
7
|
require "i18n"
|
|
8
8
|
require "zeitwerk"
|
|
9
|
+
require "opentelemetry/sdk"
|
|
10
|
+
require "opentelemetry/exporter/otlp"
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
#
|
|
12
|
+
|
|
13
|
+
# The Master Control Program of Brut. This handles all the bootstrapping and setup of your app. You are not
|
|
14
|
+
# intended to use or interact with this class at all. End of line.
|
|
12
15
|
class Brut::Framework::MCP
|
|
16
|
+
|
|
17
|
+
# Create the MCP.
|
|
18
|
+
#
|
|
19
|
+
# @param [Class] app_klass subclass of {Brut::Framework::App} representing the Brut app being started up and managed.
|
|
13
20
|
def initialize(app_klass:)
|
|
14
21
|
@config = Brut::Framework::Config.new
|
|
15
22
|
@booted = false
|
|
@@ -18,6 +25,8 @@ class Brut::Framework::MCP
|
|
|
18
25
|
self.configure!
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
# Configure Brut and initialize the {Brut::Framework::App} subclass. This should, in theory, only set up values and other
|
|
29
|
+
# ancillary data needed to start the app. It should not connect to databases.
|
|
21
30
|
def configure!
|
|
22
31
|
@config.configure!
|
|
23
32
|
|
|
@@ -37,11 +46,11 @@ class Brut::Framework::MCP
|
|
|
37
46
|
end
|
|
38
47
|
SemanticLogger["Brut"].info("Logging set up")
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
locales = Dir[
|
|
49
|
+
i18n_locales_dir = Brut.container.i18n_locales_dir
|
|
50
|
+
locales = Dir[i18n_locales_dir / "*"].map { |_|
|
|
42
51
|
Pathname(_).basename
|
|
43
52
|
}
|
|
44
|
-
::I18n.load_path += Dir[
|
|
53
|
+
::I18n.load_path += Dir[i18n_locales_dir / "**/*.rb"]
|
|
45
54
|
::I18n.available_locales = locales.map(&:to_s).map(&:to_sym)
|
|
46
55
|
|
|
47
56
|
Brut.container.store(
|
|
@@ -71,6 +80,7 @@ class Brut::Framework::MCP
|
|
|
71
80
|
else
|
|
72
81
|
SemanticLogger["Brut"].info("Classes will not be auto-reloaded")
|
|
73
82
|
end
|
|
83
|
+
|
|
74
84
|
@loader.setup
|
|
75
85
|
@app = @app_klass.new
|
|
76
86
|
end
|
|
@@ -87,11 +97,30 @@ class Brut::Framework::MCP
|
|
|
87
97
|
end
|
|
88
98
|
Kernel.at_exit do
|
|
89
99
|
begin
|
|
90
|
-
|
|
100
|
+
Brut.container.sequel_db_handle.disconnect
|
|
91
101
|
rescue Sequel::DatabaseConnectionError
|
|
92
102
|
SemanticLogger["Sequel::Database"].info "Not connected to database, so not disconnecting"
|
|
93
103
|
end
|
|
94
104
|
end
|
|
105
|
+
OpenTelemetry::SDK.configure do |c|
|
|
106
|
+
c.service_name = @app.id
|
|
107
|
+
if ENV["OTEL_TRACES_EXPORTER"]
|
|
108
|
+
SemanticLogger[self.class].info "OTEL_TRACES_EXPORTER was set (to '#{ENV['OTEL_TRACES_EXPORTER']}'), so Brut's OTel logging is disabled"
|
|
109
|
+
else
|
|
110
|
+
c.add_span_processor(
|
|
111
|
+
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(
|
|
112
|
+
Brut::Instrumentation::LoggerSpanExporter.new
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Brut.container.store(
|
|
119
|
+
"tracer",
|
|
120
|
+
OpenTelemetry::SDK::Trace::Tracer,
|
|
121
|
+
"Tracer for Open Telemetry",
|
|
122
|
+
OpenTelemetry.tracer_provider.tracer(@app.id)
|
|
123
|
+
)
|
|
95
124
|
|
|
96
125
|
Sequel::Database.extension :pg_array
|
|
97
126
|
Sequel::Database.extension :brut_instrumentation
|
|
@@ -100,9 +129,10 @@ class Brut::Framework::MCP
|
|
|
100
129
|
|
|
101
130
|
Sequel::Model.db = sequel_db
|
|
102
131
|
|
|
103
|
-
|
|
104
132
|
Sequel::Model.plugin :find_bang
|
|
105
133
|
Sequel::Model.plugin :created_at
|
|
134
|
+
Sequel::Model.plugin :table_select
|
|
135
|
+
Sequel::Model.plugin :skip_saving_columns
|
|
106
136
|
|
|
107
137
|
if !Brut.container.external_id_prefix.nil?
|
|
108
138
|
Sequel::Model.plugin :external_id, global_prefix: Brut.container.external_id_prefix
|
|
@@ -113,9 +143,6 @@ class Brut::Framework::MCP
|
|
|
113
143
|
else
|
|
114
144
|
SemanticLogger["Brut"].info("Lazily loading app's classes")
|
|
115
145
|
end
|
|
116
|
-
Brut.container.instrumentation.subscribe do |event:,start:,stop:,exception:|
|
|
117
|
-
SemanticLogger["Instrumentation"].info("#{event.category}/#{event.subcategory}/#{event.name}: #{start}/#{stop} = #{stop-start}: #{exception&.message} (#{event.details})")
|
|
118
|
-
end
|
|
119
146
|
@app.boot!
|
|
120
147
|
|
|
121
148
|
require "sinatra/base"
|
|
@@ -123,14 +150,39 @@ class Brut::Framework::MCP
|
|
|
123
150
|
@sinatra_app = Class.new(Sinatra::Base)
|
|
124
151
|
@sinatra_app.include(Brut::SinatraHelpers)
|
|
125
152
|
|
|
153
|
+
message = if Brut.container.project_env.development?
|
|
154
|
+
"Form submission did not include an authenticity token. All forms must include one. To add one, use the `form_tag` helper, or include <%= component(Brut::FrontEnd::Components::Inputs::CsrfToken) %> somewhere inside your <form> tag"
|
|
155
|
+
else
|
|
156
|
+
"Forbidden"
|
|
157
|
+
end
|
|
126
158
|
default_middlewares = [
|
|
127
|
-
[
|
|
159
|
+
[ Brut::FrontEnd::Middlewares::OpenTelemetrySpan ],
|
|
160
|
+
[ Brut::FrontEnd::Middlewares::AnnotateBrutOwnedPaths ],
|
|
161
|
+
[ Brut::FrontEnd::Middlewares::Favicon ],
|
|
162
|
+
[
|
|
163
|
+
Rack::Protection::AuthenticityToken,
|
|
164
|
+
[
|
|
165
|
+
{
|
|
166
|
+
allow_if: ->(env) { env["brut.owned_path"] },
|
|
167
|
+
message: message,
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
],
|
|
128
171
|
]
|
|
129
172
|
if Brut.container.auto_reload_classes?
|
|
130
173
|
default_middlewares << Brut::FrontEnd::Middlewares::ReloadApp
|
|
131
174
|
end
|
|
132
175
|
|
|
133
|
-
middlewares = default_middlewares + @app.class.middleware
|
|
176
|
+
middlewares = default_middlewares + @app.class.middleware.map { |(middleware,args,block)|
|
|
177
|
+
if !middleware.kind_of?(Class)
|
|
178
|
+
klass = middleware.to_s.split(/::/).reduce(Module) { |mod,part|
|
|
179
|
+
mod.const_get(part)
|
|
180
|
+
}
|
|
181
|
+
[ klass, args, block ]
|
|
182
|
+
else
|
|
183
|
+
[ middleware, args, block ]
|
|
184
|
+
end
|
|
185
|
+
}
|
|
134
186
|
|
|
135
187
|
middlewares.each do |(middleware,args,block)|
|
|
136
188
|
@sinatra_app.use(middleware,*args,&block)
|
|
@@ -158,47 +210,51 @@ class Brut::Framework::MCP
|
|
|
158
210
|
@sinatra_app.send(method) do
|
|
159
211
|
args = {}
|
|
160
212
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
213
|
+
Brut.container.instrumentation.span("#{klass_name}.#{method}") do |span|
|
|
214
|
+
hook_method.parameters.each do |(type,name)|
|
|
215
|
+
if name.to_s == "**" || name.to_s == "*"
|
|
216
|
+
raise ArgumentError,"#{method.class}##{method.name} accepts '#{name}' and not keyword args. Define it in your class to accept the keyword arguments your method needs"
|
|
217
|
+
end
|
|
218
|
+
if ![ :key,:keyreq ].include?(type)
|
|
219
|
+
raise ArgumentError,"#{name} is not a keyword arg, but is a #{type}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
if name == :request_context
|
|
223
|
+
args[name] = Thread.current.thread_variable_get(:request_context)
|
|
224
|
+
elsif name == :session
|
|
225
|
+
args[name] = Brut.container.session_class.new(rack_session: session)
|
|
226
|
+
elsif name == :request
|
|
227
|
+
args[name] = request
|
|
228
|
+
elsif name == :response
|
|
229
|
+
args[name] = response
|
|
230
|
+
elsif name == :env
|
|
231
|
+
args[name] = env
|
|
232
|
+
elsif type == :keyreq
|
|
233
|
+
raise ArgumentError,"#{method} argument '#{name}' is required, but it's not available in a #{method} hook"
|
|
234
|
+
else
|
|
235
|
+
# this keyword arg has a default value which will be used
|
|
236
|
+
end
|
|
167
237
|
end
|
|
168
238
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
239
|
+
hook = klass.new
|
|
240
|
+
span.add_prefixed_attributes("#{method}.args",args.map { |k,v| [ k,v.class] }.to_h )
|
|
241
|
+
result = hook.send(method,**args)
|
|
242
|
+
span.add_attributes(result:)
|
|
243
|
+
case result
|
|
244
|
+
in URI => uri
|
|
245
|
+
redirect to(uri.to_s)
|
|
246
|
+
in Brut::FrontEnd::HttpStatus => http_status
|
|
247
|
+
halt http_status.to_i
|
|
248
|
+
in FalseClass
|
|
249
|
+
halt 500
|
|
250
|
+
in NilClass
|
|
251
|
+
nil
|
|
252
|
+
in TrueClass
|
|
253
|
+
nil
|
|
181
254
|
else
|
|
182
|
-
#
|
|
255
|
+
raise NoMatchingPatternError, "Result from #{method} hook #{klass}'s #{method} method was a #{result.class} (#{result.to_s} as a string), which cannot be used to understand the response to generate. Return nil or true if processing should proceed"
|
|
183
256
|
end
|
|
184
257
|
end
|
|
185
|
-
|
|
186
|
-
hook = klass.new
|
|
187
|
-
result = hook.send(method,**args)
|
|
188
|
-
case result
|
|
189
|
-
in URI => uri
|
|
190
|
-
redirect to(uri.to_s)
|
|
191
|
-
in Brut::FrontEnd::HttpStatus => http_status
|
|
192
|
-
halt http_status.to_i
|
|
193
|
-
in FalseClass
|
|
194
|
-
halt 500
|
|
195
|
-
in NilClass
|
|
196
|
-
nil
|
|
197
|
-
in TrueClass
|
|
198
|
-
nil
|
|
199
|
-
else
|
|
200
|
-
raise NoMatchingPatternError, "Result from #{method} hook #{klass}'s #{method} method was a #{result.class} (#{result.to_s} as a string), which cannot be used to understand the response to generate. Return nil or true if processing should proceed"
|
|
201
|
-
end
|
|
202
258
|
end
|
|
203
259
|
end
|
|
204
260
|
end
|
|
@@ -208,8 +264,9 @@ class Brut::Framework::MCP
|
|
|
208
264
|
|
|
209
265
|
@booted = true
|
|
210
266
|
end
|
|
267
|
+
# @!visibility private
|
|
211
268
|
def sinatra_app = @sinatra_app
|
|
269
|
+
# @!visibility private
|
|
212
270
|
def app = @app
|
|
213
271
|
|
|
214
|
-
|
|
215
272
|
end
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
# Manages the interpretation of dev/test/prod. The canonical instance is available via `Brut.container.project_env`. Generally, you
|
|
2
|
+
# should avoid basing logic on this, or at least contain the conditional behavior to the configuration values. But, you do you.
|
|
1
3
|
class Brut::Framework::ProjectEnvironment
|
|
4
|
+
# Create the project environment based on the string
|
|
5
|
+
# @param [String] string_value value from e.g. `ENV["RACK_ENV"]` to use to set the environment
|
|
6
|
+
# @raise [ArgumentError] if the string does not map to a known environment.
|
|
2
7
|
def initialize(string_value)
|
|
3
8
|
@value = case string_value
|
|
4
9
|
when "development" then "development"
|
|
@@ -9,10 +14,14 @@ class Brut::Framework::ProjectEnvironment
|
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
|
|
17
|
+
# @return [true|false] true is this is development
|
|
12
18
|
def development? = @value == "development"
|
|
19
|
+
# @return [true|false] true is this is test
|
|
13
20
|
def test? = @value == "test"
|
|
21
|
+
# @return [true|false] true is this is production
|
|
14
22
|
def production? = @value == "production"
|
|
15
23
|
|
|
24
|
+
# @return [String] the string value (which should be suitable for the constructor)
|
|
16
25
|
def to_s = @value
|
|
17
26
|
end
|
|
18
27
|
|
data/lib/brut/framework.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
# Class to provide access to the asset metadata used to serve up hashed assets. Generally, you will not interact with this class.
|
|
2
|
+
#
|
|
3
|
+
# @!visibility private
|
|
2
4
|
class Brut::FrontEnd::AssetMetadata
|
|
5
|
+
|
|
6
|
+
# @param [String] asset_metadata_file to the asset metadata file
|
|
7
|
+
# @param [IO] out IO on which to write messaging
|
|
3
8
|
def initialize(asset_metadata_file:,out:$stdout)
|
|
4
9
|
@asset_metadata_file = asset_metadata_file
|
|
5
10
|
@out = out
|
|
@@ -49,6 +54,7 @@ class Brut::FrontEnd::AssetMetadata
|
|
|
49
54
|
end
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# @!visibility private
|
|
52
58
|
class ESBuildMetafile
|
|
53
59
|
def initialize(metafile:)
|
|
54
60
|
@metafile = metafile
|
|
@@ -2,46 +2,40 @@ require "json"
|
|
|
2
2
|
require "rexml"
|
|
3
3
|
require_relative "template"
|
|
4
4
|
|
|
5
|
+
# Components holds Brut-provided components that are of general use to any web app
|
|
5
6
|
module Brut::FrontEnd::Components
|
|
6
7
|
autoload(:FormTag,"brut/front_end/components/form_tag")
|
|
7
8
|
autoload(:Input,"brut/front_end/components/input")
|
|
8
9
|
autoload(:Inputs,"brut/front_end/components/input")
|
|
9
10
|
autoload(:I18nTranslations,"brut/front_end/components/i18n_translations")
|
|
10
|
-
autoload(:
|
|
11
|
+
autoload(:Time,"brut/front_end/components/time")
|
|
11
12
|
autoload(:PageIdentifier,"brut/front_end/components/page_identifier")
|
|
12
13
|
autoload(:LocaleDetection,"brut/front_end/components/locale_detection")
|
|
14
|
+
autoload(:ConstraintViolations,"brut/front_end/components/constraint_violations")
|
|
15
|
+
autoload(:Traceparent,"brut/front_end/components/traceparent")
|
|
13
16
|
end
|
|
17
|
+
|
|
14
18
|
# A Component is the top level class for managing the rendering of
|
|
15
19
|
# content. A component is essentially an ERB template and a class whose
|
|
16
|
-
# instance servces as it's binding.
|
|
20
|
+
# instance servces as it's binding. It is very similar to a View Component, though
|
|
21
|
+
# not quite as fancy.
|
|
22
|
+
#
|
|
23
|
+
# When subclassing this to create a component, your initializer's signature will determine what data
|
|
24
|
+
# is required for your component to work. It can be anything, just keep in mind that any page or component
|
|
25
|
+
# that uses your component must be able to provide those values.
|
|
26
|
+
#
|
|
27
|
+
# If your component does not override {#render} (which, generally, it won't), an ERB file is expected to exist alongside it in the
|
|
28
|
+
# app. For example, if you have a component named `Auth::LoginButtonComponent`, it would expected to be in
|
|
29
|
+
# `app/src/front_end/components/auth/login_button_component.rb`. Thus, Brut will also expect
|
|
30
|
+
# `app/src/front_end/components/auth/login_button_component.html.erb` to exist as well. That ERB file is used with an instance of your
|
|
31
|
+
# component's class to render the component's HTML.
|
|
17
32
|
#
|
|
18
|
-
#
|
|
33
|
+
# @see Brut::FrontEnd::Component::Helpers
|
|
19
34
|
class Brut::FrontEnd::Component
|
|
20
35
|
using Brut::FrontEnd::Templates::HTMLSafeString::Refinement
|
|
21
36
|
|
|
22
|
-
class TemplateLocator
|
|
23
|
-
def initialize(paths:, extension:)
|
|
24
|
-
@paths = Array(paths).map { |path| Pathname(path) }
|
|
25
|
-
@extension = extension
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def locate(base_name)
|
|
29
|
-
paths_to_try = @paths.map { |path|
|
|
30
|
-
path / "#{base_name}.#{@extension}"
|
|
31
|
-
}
|
|
32
|
-
paths_found = paths_to_try.select { |path|
|
|
33
|
-
path.exist?
|
|
34
|
-
}
|
|
35
|
-
if paths_found.empty?
|
|
36
|
-
raise "Could not locate template for #{base_name}. Tried: #{paths_to_try.map(&:to_s).join(', ')}"
|
|
37
|
-
end
|
|
38
|
-
if paths_found.length > 1
|
|
39
|
-
raise "Found more than one valid pat for #{base_name}. You must rename your files to disambiguate them. These paths were all found: #{paths_found.map(&:to_s).join(', ')}"
|
|
40
|
-
end
|
|
41
|
-
return paths_found[0]
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
37
|
|
|
38
|
+
# @!visibility private
|
|
45
39
|
class AssetPathResolver
|
|
46
40
|
def initialize(metadata_file:)
|
|
47
41
|
@metadata_file = metadata_file
|
|
@@ -58,8 +52,10 @@ class Brut::FrontEnd::Component
|
|
|
58
52
|
end
|
|
59
53
|
end
|
|
60
54
|
|
|
55
|
+
# Allows helpers that create components to pass the block they were given to the component
|
|
61
56
|
attr_writer :yielded_block
|
|
62
57
|
|
|
58
|
+
# Intended to be called by subclasses to render the yielded block wherever it makes sense in their markup.
|
|
63
59
|
def render_yielded_block
|
|
64
60
|
if @yielded_block
|
|
65
61
|
@yielded_block.().html_safe!
|
|
@@ -69,17 +65,25 @@ class Brut::FrontEnd::Component
|
|
|
69
65
|
end
|
|
70
66
|
|
|
71
67
|
# The core method of a component. This is expected to return
|
|
72
|
-
# a string to be sent as a response to an HTTP request.
|
|
68
|
+
# a string to be sent as a response to an HTTP request. Generally, you should not call this method
|
|
69
|
+
# as it is intended to be called from {Brut::FrontEnd::Component::Helpers#component}.
|
|
73
70
|
#
|
|
74
71
|
# This implementation uses the associated template for the component
|
|
75
72
|
# and sends it through ERB using this component as
|
|
76
73
|
# the binding.
|
|
74
|
+
#
|
|
75
|
+
# You may override this method to provide your own HTML for the component. In doing so, you can add
|
|
76
|
+
# keyword args for data from the `RequestContext` you wish to receive. See {Brut::FrontEnd::RequestContext#as_method_args}.
|
|
77
|
+
#
|
|
78
|
+
# @return [Brut::FrontEnd::Templates::HTMLSafeString] string containing the component's HTML.
|
|
77
79
|
def render
|
|
78
80
|
Brut.container.component_locator.locate(self.template_name).
|
|
79
81
|
then { |erb_file| Brut::FrontEnd::Template.new(erb_file) }.
|
|
80
82
|
then { |template| template.render_template(self).html_safe! }
|
|
81
83
|
end
|
|
82
84
|
|
|
85
|
+
# For components that are private to a page, this returns the name of the page they are a part of.
|
|
86
|
+
# This is used to allow a component to render a page's I18n strings.
|
|
83
87
|
def page_name
|
|
84
88
|
@page_name ||= begin
|
|
85
89
|
page = self.class.name.split(/::/).reduce(Module) { |accumulator,class_path_part|
|
|
@@ -97,7 +101,9 @@ class Brut::FrontEnd::Component
|
|
|
97
101
|
end
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# Used when an I18n string needs access to component-specific translations
|
|
100
105
|
def self.component_name = self.name
|
|
106
|
+
# (see .component_name)
|
|
101
107
|
def component_name = self.class.component_name
|
|
102
108
|
|
|
103
109
|
# Helper methods that subclasses can use.
|
|
@@ -110,27 +116,45 @@ class Brut::FrontEnd::Component
|
|
|
110
116
|
|
|
111
117
|
# Render a component. This is the primary way in which
|
|
112
118
|
# view re-use happens. The component instance will be able to locate its
|
|
113
|
-
# HTML template and render itself.
|
|
119
|
+
# HTML template and render itself. {#render} is called with variables from the `RequestContext`
|
|
120
|
+
# as described in {Brut::FrontEnd::RequestContext#as_method_args}
|
|
121
|
+
#
|
|
122
|
+
# @param [Brut::FrontEnd::Component|Class] component_instance instance of the component to render. If a `Class`
|
|
123
|
+
# is passed, it must extend {Brut::FrontEnd::Component}. It will created
|
|
124
|
+
# based on the logic described in {Brut::FrontEnd::RequestContext#as_constructor_args}.
|
|
125
|
+
# You would do this if your component needs to be injected with information
|
|
126
|
+
# not available to the page or component that is using it.
|
|
127
|
+
# @yield this block is passed to the `component_instance` via {#yielded_block=}.
|
|
128
|
+
#
|
|
129
|
+
# @return [Brut::FrontEnd::Templates::HTMLSafeString] of the rendered component.
|
|
114
130
|
def component(component_instance,&block)
|
|
131
|
+
component_name = component_instance.kind_of?(Class) ? component_instance.name : component_instance.class.name
|
|
132
|
+
Brut.container.instrumentation.span(component_name) do |span|
|
|
115
133
|
if component_instance.kind_of?(Class)
|
|
116
134
|
if !component_instance.ancestors.include?(Brut::FrontEnd::Component)
|
|
117
135
|
raise ArgumentError,"#{component_instance} is not a component and cannot be created"
|
|
118
136
|
end
|
|
119
|
-
Thread.current.thread_variable_get(:request_context).
|
|
137
|
+
component_instance = Thread.current.thread_variable_get(:request_context).
|
|
120
138
|
then { |request_context| request_context.as_constructor_args(component_instance,request_params: nil)
|
|
121
|
-
}.then { |constructor_args| component_instance.new(**constructor_args)
|
|
122
|
-
|
|
139
|
+
}.then { |constructor_args| component_instance.new(**constructor_args) }
|
|
140
|
+
span.add_attributes("global_component" => true, "class" => component_instance.class.name)
|
|
141
|
+
else
|
|
142
|
+
span.add_attributes("global_component" => false, "class" => component_instance.class.name)
|
|
123
143
|
end
|
|
124
144
|
if !block.nil?
|
|
125
145
|
component_instance.yielded_block = block
|
|
126
146
|
end
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}.then { |render_args|
|
|
147
|
+
Thread.current.thread_variable_get(:request_context).then {
|
|
148
|
+
it.as_method_args(component_instance,:render,request_params: nil, form: nil)
|
|
149
|
+
}.then { |render_args|
|
|
150
|
+
component_instance.render(**render_args).html_safe!
|
|
130
151
|
}
|
|
152
|
+
end
|
|
131
153
|
end
|
|
132
154
|
|
|
133
155
|
# Inline an SVG into the page.
|
|
156
|
+
#
|
|
157
|
+
# @param [String] svg name of an SVG file, relative to where SVGs are stored.
|
|
134
158
|
def svg(svg)
|
|
135
159
|
Brut.container.svg_locator.locate(svg).then { |svg_file|
|
|
136
160
|
File.read(svg_file).html_safe!
|
|
@@ -141,19 +165,67 @@ class Brut::FrontEnd::Component
|
|
|
141
165
|
# the same value, but with any content hashes that are part of the filename.
|
|
142
166
|
def asset_path(path) = Brut.container.asset_path_resolver.resolve(path)
|
|
143
167
|
|
|
144
|
-
#
|
|
145
|
-
|
|
146
|
-
|
|
168
|
+
# Creates the form surrounding the contents of the block yielded to it. If the form's action is a POST, it will include a CSRF token.
|
|
169
|
+
# If the form's action is GET, it will not.
|
|
170
|
+
#
|
|
171
|
+
# @example Route without parameters
|
|
172
|
+
# <%= form_tag(for: NewWidgetForm, class: "new-form") do %>
|
|
173
|
+
# <input type="text" name="name">
|
|
174
|
+
# <button>Create</button>
|
|
175
|
+
# <% end %>
|
|
176
|
+
#
|
|
177
|
+
# @example Route with parameters
|
|
178
|
+
# <%= form_tag(for: SaveWidgetWithIdForm, route_params: { id: widget.external_id }, class: "new-form") do %>
|
|
179
|
+
# <input type="text" name="name">
|
|
180
|
+
# <button>Save</button>
|
|
181
|
+
# <% end %>
|
|
182
|
+
#
|
|
183
|
+
# @param route_params [Hash] if the form requires route parameters, their values must be passed here so that the HTML `action`
|
|
184
|
+
# attribute can be constructed properly.
|
|
185
|
+
# @param html_attributes [Hash] any additional attributes for the `<form>` tag
|
|
186
|
+
# @option html_attributes [Class|Brut::FrontEnd::Form] :for the form object or class representing this HTML form. If you pass this, you may not pass the HTML attributes `:action` or `:method`. Both will be derived from this object.
|
|
187
|
+
# @option html_attributes [String] «any-other-key» attributes to set on the `<form>` tag
|
|
188
|
+
# @yield No parameters given. This is expected to return additional markup to appear inside the `<form>` element.
|
|
189
|
+
def form_tag(route_params: {}, **html_attributes,&contents)
|
|
190
|
+
component(Brut::FrontEnd::Components::FormTag.new(route_params:, **html_attributes,&contents))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Creates a {Brut::FrontEnd::Components::Time}.
|
|
194
|
+
#
|
|
195
|
+
# @param timestamp [Time] the timestamp to format/render. Mutually exclusive with `date`.
|
|
196
|
+
# @param date [Date] the date to format/render. Mutually exclusive with `timestamp`.
|
|
197
|
+
# @param component_options [Hash] keyword arguments to pass to {Brut::FrontEnd::Components::Time#initialize}
|
|
198
|
+
def time_tag(timestamp:nil,date:nil, **component_options)
|
|
199
|
+
args = component_options.merge(timestamp:,date:)
|
|
200
|
+
component(Brut::FrontEnd::Components::Time.new(**args))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Render the {Brut::FrontEnd::Components::ConstraintViolations} component for the given form's input.
|
|
204
|
+
def constraint_violations(form:, input_name:, message_html_attributes: {}, **html_attributes)
|
|
205
|
+
component(
|
|
206
|
+
Brut::FrontEnd::Components::ConstraintViolations.new(
|
|
207
|
+
form:,
|
|
208
|
+
input_name:,
|
|
209
|
+
message_html_attributes:,
|
|
210
|
+
**html_attributes
|
|
211
|
+
)
|
|
212
|
+
)
|
|
147
213
|
end
|
|
148
214
|
|
|
149
|
-
|
|
150
|
-
|
|
215
|
+
# Create an HTML input tag for the given input of a form. This is a convieniece method
|
|
216
|
+
# that calls {Brut::FrontEnd::Components::Input::TextField.for_form_input}.
|
|
217
|
+
def input_tag(form:, input_name:, index: nil, **html_attributes)
|
|
218
|
+
component(Brut::FrontEnd::Components::Inputs::TextField.for_form_input(form:,input_name:,index:,html_attributes:))
|
|
151
219
|
end
|
|
152
220
|
|
|
221
|
+
# Indicates a given string is safe to render directly as HTML. No escaping will happen.
|
|
222
|
+
#
|
|
223
|
+
# @param [String] string a string that should be marked as HTML safe
|
|
153
224
|
def html_safe!(string)
|
|
154
225
|
string.html_safe!
|
|
155
226
|
end
|
|
156
227
|
|
|
228
|
+
# @!visibility private
|
|
157
229
|
VOID_ELEMENTS = [
|
|
158
230
|
:area,
|
|
159
231
|
:base,
|
|
@@ -170,6 +242,25 @@ class Brut::FrontEnd::Component
|
|
|
170
242
|
:wbr,
|
|
171
243
|
]
|
|
172
244
|
|
|
245
|
+
# Generate an HTML element safely in code. This is useful if you don't want to create
|
|
246
|
+
# a separate ERB file, but still want to create a component.
|
|
247
|
+
#
|
|
248
|
+
# @param [String|Symbol] tag_name the name of the HTML tag to create.
|
|
249
|
+
# @param [Hash] html_attributes all the HTML attributes you wish to include in the element that is generated. Values that
|
|
250
|
+
# are `true` will be included without a value, and values that are `false` will be omitted.
|
|
251
|
+
# @yield Called to get any contents that should be put into this tag. Void elements as defined by W3C may not have a block.
|
|
252
|
+
#
|
|
253
|
+
# @example Void element
|
|
254
|
+
#
|
|
255
|
+
# html_tag(:img, src: "trellick.png") # => <img src="trellic.png">
|
|
256
|
+
#
|
|
257
|
+
# @example Nested elements
|
|
258
|
+
#
|
|
259
|
+
# html_tag(:nav, class: "flex items-center") do
|
|
260
|
+
# html_tag(:a, href="/") { "Home" } +
|
|
261
|
+
# html_tag(:a, href="/about") { "About" } +
|
|
262
|
+
# html_tag(:a, href="/contact") { "Contact" }
|
|
263
|
+
# end
|
|
173
264
|
def html_tag(tag_name, **html_attributes, &block)
|
|
174
265
|
tag_name = tag_name.to_s.downcase.to_sym
|
|
175
266
|
attributes_string = html_attributes.map { |key,value|
|