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,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|
|