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
@@ -0,0 +1,46 @@
|
|
1
|
+
class Brut::FrontEnd::Forms::RadioButtonGroupInput
|
2
|
+
|
3
|
+
extend Forwardable
|
4
|
+
|
5
|
+
# (see Brut::FrontEnd::Forms::Input#value)
|
6
|
+
attr_reader :value
|
7
|
+
# (see Brut::FrontEnd::Forms::Input#validity_state)
|
8
|
+
attr_reader :validity_state
|
9
|
+
|
10
|
+
# (see Brut::FrontEnd::Forms::Input#initialize)
|
11
|
+
def initialize(input_definition:, value:)
|
12
|
+
@input_definition = input_definition
|
13
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new
|
14
|
+
if input_definition.array?
|
15
|
+
value ||= []
|
16
|
+
end
|
17
|
+
self.value=(value)
|
18
|
+
end
|
19
|
+
|
20
|
+
def_delegators :"@input_definition", :name,
|
21
|
+
:required,
|
22
|
+
:array?
|
23
|
+
|
24
|
+
# (see Brut::FrontEnd::Forms::Input#value=)
|
25
|
+
def value=(new_value)
|
26
|
+
value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
|
27
|
+
missing = if self.required
|
28
|
+
value_missing
|
29
|
+
else
|
30
|
+
false
|
31
|
+
end
|
32
|
+
|
33
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new(
|
34
|
+
value_missing: missing,
|
35
|
+
)
|
36
|
+
@value = new_value
|
37
|
+
end
|
38
|
+
|
39
|
+
# (see Brut::FrontEnd::Forms::Input#server_side_constraint_violation)
|
40
|
+
def server_side_constraint_violation(key,context=true)
|
41
|
+
@validity_state.server_side_constraint_violation(key: key, context: context)
|
42
|
+
end
|
43
|
+
|
44
|
+
# (see Brut::FrontEnd::Forms::Input#valid?)
|
45
|
+
def valid? = @validity_state.valid?
|
46
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# Defines a radio button group for a form, but not it's runtime state (which includes how many radio buttons would need to be
|
2
|
+
# rendered). See {Brut::FrontEnd::Forms::RadioButtonInput}.
|
3
|
+
#
|
4
|
+
# Note that this ultimately defines the contents for a `<input type="radio">` tag, so the constraints you can place are only those
|
5
|
+
# supported by the browser. Also note that arrays of radio button groups are not currently supported.
|
6
|
+
class Brut::FrontEnd::Forms::RadioButtonGroupInputDefinition
|
7
|
+
include Brut::Framework::FussyTypeEnforcement
|
8
|
+
attr_reader :required, :name
|
9
|
+
# Create the input definition
|
10
|
+
# @param [String] name Name of the input (required)
|
11
|
+
# @param [true|false] required true if this field is required, false otherwise. Default is `true`.
|
12
|
+
# @param [true|false] array If true, an error is raised as this is not yet supported
|
13
|
+
def initialize(name:, required: true, array: false)
|
14
|
+
name = name.to_s
|
15
|
+
@name = type!( name , String , "name", required: true)
|
16
|
+
@required = type!( required , [true, false] , "required", required: true)
|
17
|
+
@array = type!( array , [true, false] , "array", required: true)
|
18
|
+
if @array
|
19
|
+
raise Brut::Framework::Errors::NotImplemented, "Arrays of radio button groups are not yet supported"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def array? = @array
|
24
|
+
|
25
|
+
# Create an Input based on this defitition, initializing it with the given value.
|
26
|
+
def make_input(value:)
|
27
|
+
Brut::FrontEnd::Forms::RadioButtonGroupInput.new(input_definition: self, value: value)
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# Like {Brut::FrontEnd::Forms::Input}, this models a `<SELECT>`'s current state and validity.
|
2
|
+
class Brut::FrontEnd::Forms::SelectInput
|
3
|
+
|
4
|
+
extend Forwardable
|
5
|
+
|
6
|
+
# (see Brut::FrontEnd::Forms::Input#value)
|
7
|
+
attr_reader :value
|
8
|
+
# (see Brut::FrontEnd::Forms::Input#validity_state)
|
9
|
+
attr_reader :validity_state
|
10
|
+
|
11
|
+
# (see Brut::FrontEnd::Forms::Input#initialize)
|
12
|
+
def initialize(input_definition:, value:)
|
13
|
+
@input_definition = input_definition
|
14
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new
|
15
|
+
if input_definition.array?
|
16
|
+
value ||= []
|
17
|
+
end
|
18
|
+
self.value=(value)
|
19
|
+
end
|
20
|
+
|
21
|
+
def_delegators :"@input_definition", :name,
|
22
|
+
:required,
|
23
|
+
:array?
|
24
|
+
|
25
|
+
# (see Brut::FrontEnd::Forms::Input#value=)
|
26
|
+
def value=(new_value)
|
27
|
+
value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
|
28
|
+
missing = if self.required
|
29
|
+
value_missing
|
30
|
+
else
|
31
|
+
false
|
32
|
+
end
|
33
|
+
|
34
|
+
@validity_state = Brut::FrontEnd::Forms::ValidityState.new(
|
35
|
+
value_missing: missing,
|
36
|
+
)
|
37
|
+
@value = new_value
|
38
|
+
end
|
39
|
+
|
40
|
+
# (see Brut::FrontEnd::Forms::Input#server_side_constraint_violation)
|
41
|
+
def server_side_constraint_violation(key,context=true)
|
42
|
+
@validity_state.server_side_constraint_violation(key: key, context: context)
|
43
|
+
end
|
44
|
+
|
45
|
+
# (see Brut::FrontEnd::Forms::Input#valid?)
|
46
|
+
def valid? = @validity_state.valid?
|
47
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# Defines a `<select>` for a form, but not it's current runtime state. {Brut::FrontEnd::Forms::SelectInput} is used to understand the current state or value of a select.
|
2
|
+
#
|
3
|
+
# Note that a select input definition is defining an HTML `<select>`, not a generic attribute. Thus, the only constraints you can place on
|
4
|
+
# an input are those that the browser supports. If your form needs server side validation, you can accomplish that in a lot of ways,
|
5
|
+
# such as implementing a {Brut::BackEnd::Validators::FormValidator}, or calling
|
6
|
+
# {Brut::FrontEnd::Form#server_side_constraint_violation} directly.
|
7
|
+
class Brut::FrontEnd::Forms::SelectInputDefinition
|
8
|
+
include Brut::Framework::FussyTypeEnforcement
|
9
|
+
attr_reader :required, :name
|
10
|
+
# Create the input definition
|
11
|
+
# @param [String] name Name of the input (required)
|
12
|
+
# @param [true|false] required true if this field is required, false otherwise. Default is `true`.
|
13
|
+
# @param [true|false] array If true, the form will expect multiple values for this input. The values will be available as an array. Any values omitted by the user will be present as empty strings.
|
14
|
+
def initialize(name:, required: true, array: false)
|
15
|
+
name = name.to_s
|
16
|
+
@name = type!( name , String , "name", required: true)
|
17
|
+
@required = type!( required , [true, false] , "required", required: true)
|
18
|
+
@array = type!( array , [true, false] , "array", required: true)
|
19
|
+
end
|
20
|
+
|
21
|
+
def array? = @array
|
22
|
+
|
23
|
+
# Create an Input based on this defitition, initializing it with the given value.
|
24
|
+
def make_input(value:)
|
25
|
+
Brut::FrontEnd::Forms::SelectInput.new(input_definition: self, value: value)
|
26
|
+
end
|
27
|
+
end
|
@@ -1,15 +1,19 @@
|
|
1
|
-
# Mirrors a web browser's ValidityState API
|
2
|
-
#
|
3
|
-
#
|
4
|
-
#
|
5
|
-
#
|
6
|
-
#
|
1
|
+
# Mirrors a web browser's ValidityState API, but can also capture additional arbitrary server-side
|
2
|
+
# constraint violations to create an entire picture of all constraints violated by a given form input.
|
3
|
+
# In a sense, this is a wrapper for one or more {Brut::FrontEnd::Forms::ConstraintViolation} instances in the
|
4
|
+
# context of an input.
|
5
|
+
#
|
6
|
+
# @see https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
|
7
7
|
class Brut::FrontEnd::Forms::ValidityState
|
8
8
|
include Enumerable
|
9
9
|
|
10
|
+
# Create a validity state initialized with the given violations
|
11
|
+
#
|
12
|
+
# @param [Hash<String,true|false>] constraint_violations map of keys to booleans, where if the boolean is true, there is a
|
13
|
+
# constraint violation described by the key. The keys are i18n fragments used to construct error messages.
|
10
14
|
def initialize(constraint_violations={})
|
11
|
-
@constraint_violations = constraint_violations.map { |key,
|
12
|
-
if
|
15
|
+
@constraint_violations = constraint_violations.map { |key,is_violation|
|
16
|
+
if is_violation
|
13
17
|
Brut::FrontEnd::Forms::ConstraintViolation.new(key: key, context: {})
|
14
18
|
else
|
15
19
|
nil
|
@@ -17,15 +21,25 @@ class Brut::FrontEnd::Forms::ValidityState
|
|
17
21
|
}.compact
|
18
22
|
end
|
19
23
|
|
20
|
-
# Returns true if there are no
|
24
|
+
# Returns true if there are no constraint violations
|
21
25
|
def valid? = @constraint_violations.empty?
|
22
26
|
|
27
|
+
# Returns true if there are constraint violations
|
28
|
+
def constraint_violations? = !self.valid?
|
29
|
+
|
23
30
|
# Set a server-side constraint violation. This is essentially arbitrary and dependent
|
24
31
|
# on your use-case.
|
32
|
+
#
|
33
|
+
# @param [String|Symbol] key an I18n key fragment used to create a message about the violation
|
34
|
+
# @param [Hash] context interpolated values used to create the message
|
25
35
|
def server_side_constraint_violation(key:,context:)
|
26
36
|
@constraint_violations << Brut::FrontEnd::Forms::ConstraintViolation.new(key: key, context: context, server_side: true)
|
27
37
|
end
|
28
38
|
|
39
|
+
# Iterate over each constraint violation
|
40
|
+
#
|
41
|
+
# @yield [constraint] called once for each constraint violation
|
42
|
+
# @yieldparam constraint [Brut::FrontEnd::Forms::ConstraintViolation]
|
29
43
|
def each(&block)
|
30
44
|
@constraint_violations.each do |constraint|
|
31
45
|
block.call(constraint)
|
@@ -1,18 +1,34 @@
|
|
1
|
-
# A handler responds to all HTTP requests other than those that render a page. It will be given any data it needs
|
2
|
-
# to handle the request to its handle method. You define this method to accept the parameters you expect.
|
3
|
-
#
|
4
|
-
# You may also define before_handle which will be given any subset of those parameters and can perform logic before
|
5
|
-
# handle is called. This is most useful in a base class to check for permissions or other cross-cutting concerns.
|
6
|
-
#
|
7
|
-
# Tests should call handle!
|
8
1
|
module Brut::FrontEnd
|
2
|
+
# A handler responds to all HTTP requests other than those that render a page. It will be given any data it needs
|
3
|
+
# to handle the request to its {#handle} method, which you must implement.
|
4
|
+
# You define this method to accept the parameters you expect. See {Brut::FrontEnd::RequestContext} for how that works.
|
5
|
+
#
|
6
|
+
# You may also define `before_handle` which will be given any subset of those parameters and can perform logic before
|
7
|
+
# handle is called. This is most useful in a base class to check for permissions or other cross-cutting concerns.
|
8
|
+
#
|
9
|
+
# The primary method of this class is {#handle!} which you should not override, but *should* call in a test.
|
9
10
|
class Handler
|
10
11
|
include Brut::FrontEnd::HandlingResults
|
12
|
+
include Brut::Framework::Errors
|
11
13
|
|
14
|
+
# You must implement this to accept whatever parameters you need. See {Brut::FrontEnd::RequestContext} for how that works.
|
15
|
+
# The type of the return value determines what will happen:
|
16
|
+
#
|
17
|
+
# * Instance of `URI` - browser will redirect to this URI. Typically, you would do this by calling {Brut::FrontEnd::HandlingResults#redirect_to}.
|
18
|
+
# * Instance of {Brut::FrontEnd::Component} (which notably includes {Brut::FrontEnd::Page}) - renders that component or page
|
19
|
+
# * Array of two items, with the first being an Instance of {Brut::FrontEnd::Component} and the second being an {Brut::FrontEnd::HttpStatus} - renders that component or page, but responds with the given HTTP status. Useful for Ajax requests that don't return 200, but do return useful content.
|
20
|
+
# * Instance of {Brut::FrontEnd::HttpStatus} - returns just that status code. Typically you would do this by calling {Brut::FrontEnd::HandlingResults#http_status}
|
21
|
+
# * Instance of {Brut::FrontEnd::Download} - sends a file download to the browser.
|
22
|
+
#
|
23
|
+
# @return [URI|Brut::FrontEnd::Component,Array,Brut::FrontEnd::HttpStatus,Brut::FrontEnd::Download]
|
12
24
|
def handle(**)
|
13
|
-
|
25
|
+
abstract_method!
|
14
26
|
end
|
15
27
|
|
28
|
+
# Called by Brut to handle the request. Do not override this. If your handler responds to `before_handle` that is called with the
|
29
|
+
# same args as you have defined for {#handle}. If `before_handle` returns anything other than `nil`, that value is returned and
|
30
|
+
# should be one of the values documented in {#handle}. If `before_handle` returns `nil`, {#handle} is called and whatever it
|
31
|
+
# returns is returned here.
|
16
32
|
def handle!(**args)
|
17
33
|
result = nil
|
18
34
|
if self.respond_to?(:before_handle)
|
@@ -41,8 +57,11 @@ module Brut::FrontEnd
|
|
41
57
|
result
|
42
58
|
end
|
43
59
|
end
|
60
|
+
# Namespace for handlers provided by Brut
|
44
61
|
module Handlers
|
45
62
|
autoload(:CspReportingHandler,"brut/front_end/handlers/csp_reporting_handler")
|
46
63
|
autoload(:LocaleDetectionHandler,"brut/front_end/handlers/locale_detection_handler")
|
64
|
+
autoload(:MissingHandler,"brut/front_end/handlers/missing_handler")
|
65
|
+
autoload(:InstrumentationHandler,"brut/front_end/handlers/instrumentation_handler")
|
47
66
|
end
|
48
67
|
end
|
@@ -1,10 +1,12 @@
|
|
1
|
+
# Receives content security policy violations and logs them. This is set up in {Brut::Framework::MCP}, however CSP reporting is
|
2
|
+
# configured in {Brut::FrontEnd::RouteHooks::CSPNoInlineStylesOrScripts::ReportOnly}.
|
1
3
|
class Brut::FrontEnd::Handlers::CspReportingHandler < Brut::FrontEnd::Handler
|
2
4
|
def handle(body:)
|
3
5
|
begin
|
4
6
|
parsed = JSON.parse(body.read)
|
5
|
-
|
7
|
+
Brut.container.instrumentation.add_attributes(parsed)
|
6
8
|
rescue => ex
|
7
|
-
|
9
|
+
Brut.container.instrumentation.record_exception(ex)
|
8
10
|
end
|
9
11
|
http_status(200)
|
10
12
|
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
require "base64"
|
2
|
+
class Brut::FrontEnd::Handlers::InstrumentationHandler < Brut::FrontEnd::Handler
|
3
|
+
Event = Data.define(:name, :timestamp, :attributes) do
|
4
|
+
def self.from_json(json)
|
5
|
+
name = json["name"]
|
6
|
+
timestamp = Time.at(json["timestamp"].to_i / 1000.0)
|
7
|
+
attributes = json["attributes"] || {}
|
8
|
+
self.new(name:,timestamp:,attributes:)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
Span = Data.define(:name,:start_timestamp,:end_timestamp,:attributes,:events,:spans) do
|
13
|
+
def self.from_json(json)
|
14
|
+
name = json["name"]
|
15
|
+
start_timestamp = Time.at(json["start_timestamp"].to_i / 1000.0)
|
16
|
+
end_timestamp = Time.at(json["end_timestamp"].to_i / 1000.0)
|
17
|
+
attributes = json["attributes"] || {}
|
18
|
+
events = (json["events"] || []).map { Event.from_json(it) }
|
19
|
+
spans = (json["spans"] || []).map { Span.from_json(it) }
|
20
|
+
self.new(name:,start_timestamp:,end_timestamp:,attributes:,events:,spans:)
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.from_header(header_value)
|
24
|
+
if header_value.nil?
|
25
|
+
return nil
|
26
|
+
end
|
27
|
+
if header_value.kind_of?(self)
|
28
|
+
return header_value
|
29
|
+
end
|
30
|
+
|
31
|
+
# This header can have info for several vendors, delimited by commas. We pick
|
32
|
+
# out ours, which has a vendor name 'brut'
|
33
|
+
brut_state = header_value.split(/\s*,\s*/).map { it.split(/\s*=\s*/) }.detect { |vendor,_|
|
34
|
+
vendor == "brut"
|
35
|
+
}[1]
|
36
|
+
|
37
|
+
# Our state is a base-64 encoded JSON blob
|
38
|
+
# each key/value separated by a colon.
|
39
|
+
json = Base64.decode64(brut_state)
|
40
|
+
|
41
|
+
hash = JSON.parse(json)
|
42
|
+
if !hash.kind_of?(Hash)
|
43
|
+
SemanticLogger[self.class].info "Got a #{hash.class} and not a Hash"
|
44
|
+
return nil
|
45
|
+
end
|
46
|
+
self.from_json(hash)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
class TraceParent
|
51
|
+
def self.from_header(header_value)
|
52
|
+
if header_value.nil?
|
53
|
+
return nil
|
54
|
+
elsif header_value.kind_of?(self)
|
55
|
+
return header_value
|
56
|
+
else
|
57
|
+
return TraceParent.new(header_value)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def initialize(value)
|
62
|
+
@value = value
|
63
|
+
end
|
64
|
+
|
65
|
+
def as_carrier = { "traceparent" => @value }
|
66
|
+
end
|
67
|
+
|
68
|
+
def handle(http_tracestate:, http_traceparent:)
|
69
|
+
traceparent = TraceParent.from_header(http_traceparent)
|
70
|
+
span = Span.from_header(http_tracestate)
|
71
|
+
|
72
|
+
if span.nil? || traceparent.nil?
|
73
|
+
SemanticLogger[self.class].info "Missing traceparent or span: #{http_tracestate}, #{http_traceparent}"
|
74
|
+
return http_status(400)
|
75
|
+
end
|
76
|
+
|
77
|
+
carrier = traceparent.as_carrier
|
78
|
+
propagator = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
79
|
+
extracted_context = propagator.extract(carrier)
|
80
|
+
OpenTelemetry::Context.with_current(extracted_context) do
|
81
|
+
record_span(span)
|
82
|
+
end
|
83
|
+
http_status(200)
|
84
|
+
end
|
85
|
+
|
86
|
+
private
|
87
|
+
|
88
|
+
def record_span(span)
|
89
|
+
otel_span = Brut.container.tracer.start_span(span.name, start_timestamp: span.start_timestamp, attributes: span.attributes)
|
90
|
+
span.events.each do |event|
|
91
|
+
otel_span.add_event(event.name,timestamp: event.timestamp, attributes: event.attributes)
|
92
|
+
end
|
93
|
+
span.spans.each do |inner_span|
|
94
|
+
record_span(inner_span)
|
95
|
+
end
|
96
|
+
otel_span.finish(end_timestamp: span.end_timestamp)
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
@@ -1,8 +1,15 @@
|
|
1
|
+
# Receives the Ajax request containing the browser's JavaScript engine's understanding of the user's locale.
|
2
|
+
# This is configured in {Brut::Framework::MCP}, however the requests are initiated from the HTML custom element generated by
|
3
|
+
# {Brut::FrontEnd::Components::LocaleDetection}. This will set the timezone on the session via
|
4
|
+
# {Brut::FrontEnd::Session#timezone_from_browser=}, and set the
|
5
|
+
# {Brut::FrontEnd::Session#http_accept_language=} *only* if the `Accept-Language` header did not provide a value that is supported by
|
6
|
+
# the app.
|
1
7
|
class Brut::FrontEnd::Handlers::LocaleDetectionHandler < Brut::FrontEnd::Handler
|
2
8
|
def handle(body:,session:)
|
3
9
|
begin
|
4
10
|
parsed = JSON.parse(body.read)
|
5
|
-
|
11
|
+
Brut.container.instrumentation.add_attributes(parsed_body: parsed)
|
12
|
+
Brut.container.instrumentation.add_attributes(parsed_class: parsed.class)
|
6
13
|
if parsed.kind_of?(Hash)
|
7
14
|
locale = parsed["locale"]
|
8
15
|
timezone = parsed["timeZone"]
|
@@ -11,11 +18,9 @@ class Brut::FrontEnd::Handlers::LocaleDetectionHandler < Brut::FrontEnd::Handler
|
|
11
18
|
if !session.http_accept_language.known?
|
12
19
|
session.http_accept_language = Brut::I18n::HTTPAcceptLanguage.from_browser(locale)
|
13
20
|
end
|
14
|
-
else
|
15
|
-
SemanticLogger["brut:__brut/locale"].warn("Got a #{parsed.class} from /__brut/locale instead of a hash")
|
16
21
|
end
|
17
22
|
rescue => ex
|
18
|
-
|
23
|
+
Brut.container.instrumentation.record_exception(ex)
|
19
24
|
end
|
20
25
|
http_status(200)
|
21
26
|
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# Used in development to handle a defined route but a missing page. This arranges to render a nicer error page than the default.
|
2
|
+
class Brut::FrontEnd::Handlers::MissingHandler < Brut::FrontEnd::Handler
|
3
|
+
def handle(route:)
|
4
|
+
Brut::FrontEnd::Pages::MissingPage.new(route:)
|
5
|
+
end
|
6
|
+
|
7
|
+
class Form < Brut::FrontEnd::Form
|
8
|
+
end
|
9
|
+
end
|
@@ -1,7 +1,17 @@
|
|
1
|
+
# Convienience methods to use inside handlers to make it easier to return richly typed results.
|
2
|
+
#
|
3
|
+
# @see Brut::FrontEnd::Handler
|
1
4
|
module Brut::FrontEnd::HandlingResults
|
2
|
-
#
|
3
|
-
#
|
4
|
-
#
|
5
|
+
# Return this to cause your handler to redirect to `klass`' route with the given query string parameters.
|
6
|
+
#
|
7
|
+
# @param [Class] klass A page or handler class whose route should be redirected-to. Note that if parameters are required, they must
|
8
|
+
# be provided in `query_string_params` or this will raise an error. Note that the class must be for a GET route, since you cannot
|
9
|
+
# redirect to a non-GET.
|
10
|
+
# @param [Hash] query_string_params arguments and parameters for the route. Any values that correspond to route parameters will be
|
11
|
+
# used to build the route. Remaining will be used as query parameters.
|
12
|
+
#
|
13
|
+
# @raise [ArgumentError] if `klass` is not a `Class` or if `klass` is not for a `GET`
|
14
|
+
# @raise [Brut::Framework::Errors::MissingParameter] if any required route parameters were not provided
|
5
15
|
def redirect_to(klass, **query_string_params)
|
6
16
|
if !klass.kind_of?(Class)
|
7
17
|
raise ArgumentError,"redirect_to should be given a Class, not a #{klass.class}"
|
@@ -9,6 +19,6 @@ module Brut::FrontEnd::HandlingResults
|
|
9
19
|
Brut.container.routing.uri(klass,with_method: :get,**query_string_params)
|
10
20
|
end
|
11
21
|
|
12
|
-
#
|
22
|
+
# Return this to return an HTTP status code from a number or string containing the code.
|
13
23
|
def http_status(number) = Brut::FrontEnd::HttpStatus.new(number)
|
14
24
|
end
|
@@ -1,4 +1,10 @@
|
|
1
|
+
# Wrapper around an HTTP Method, ensuring it contains only a valid value.
|
1
2
|
class Brut::FrontEnd::HttpMethod
|
3
|
+
# Create an HTTP method from a string.
|
4
|
+
#
|
5
|
+
# @param [String|Symbol] string a string containing an HTTP method name. Case insensitive, and can be a symbol.
|
6
|
+
#
|
7
|
+
# @raise [ArgumentError] if the passed `string` is not a valid HTTP method
|
2
8
|
def initialize(string)
|
3
9
|
normalized = string.to_s.downcase.to_sym
|
4
10
|
if !self.class.method_names.include?(normalized)
|
@@ -7,15 +13,21 @@ class Brut::FrontEnd::HttpMethod
|
|
7
13
|
@method = normalized
|
8
14
|
end
|
9
15
|
|
16
|
+
# @return [String] the method name, normalized to all lower case, as a string
|
10
17
|
def to_s = @method.to_s
|
18
|
+
# @return [Symbol] the method name, normalized to all lower case, as a symbol
|
11
19
|
def to_sym = @method.to_sym
|
12
20
|
alias to_str to_s
|
13
21
|
|
22
|
+
# @return [true|false] True if the other object is the same class as this and has the same string representation
|
14
23
|
def ==(other)
|
15
24
|
self.class.name == other.class.name && self.to_s == other.to_s
|
16
25
|
end
|
17
26
|
|
18
|
-
|
27
|
+
# @return [true|false] true if this is a GET
|
28
|
+
def get? = self.to_sym == :get
|
29
|
+
# @return [true|false] true if this is a POST
|
30
|
+
def post? = self.to_sym == :post
|
19
31
|
|
20
32
|
private
|
21
33
|
|
@@ -1,4 +1,11 @@
|
|
1
|
+
# Wrapper around an HTTP status, that can also normalize strings that contain status codes.
|
1
2
|
class Brut::FrontEnd::HttpStatus
|
3
|
+
# Create an http status
|
4
|
+
#
|
5
|
+
# @param [Integer|String] number the status code. `to_i` is used to coerce this into a number.
|
6
|
+
#
|
7
|
+
# @raise [ArgumentError] if the value is lower than 100 or greater than 599. Note that the spec allows any value in that range to be
|
8
|
+
# considered a valid HTTP status code
|
2
9
|
def initialize(number)
|
3
10
|
number = number.to_i
|
4
11
|
if ((number < 100) || (number > 599))
|
@@ -7,9 +14,12 @@ class Brut::FrontEnd::HttpStatus
|
|
7
14
|
@number = number
|
8
15
|
end
|
9
16
|
|
17
|
+
# @return [Number] the value as a number
|
10
18
|
def to_i = @number
|
19
|
+
# @return [String] the value as a string
|
11
20
|
def to_s = to_i.to_s
|
12
21
|
|
22
|
+
# @return [true|false] true if the other object has the same class as this and has the same numeric representation
|
13
23
|
def ==(other)
|
14
24
|
self.class == other.class && self.to_i == other.to_i
|
15
25
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<meta content="width=device-width,initial-scale=1" name="viewport">
|
6
|
+
<title>BRUT INTERNAL</title>
|
7
|
+
<meta content="website" property="og:type">
|
8
|
+
<%= component(Brut::FrontEnd::Components::PageIdentifier.new(self.page_name)) %>
|
9
|
+
<style>
|
10
|
+
html {
|
11
|
+
font-family: system-ui, serif;
|
12
|
+
background: #fefefe;
|
13
|
+
color: #333;
|
14
|
+
}
|
15
|
+
code {
|
16
|
+
font-family: courier, monospace;
|
17
|
+
}
|
18
|
+
pre:has(code) {
|
19
|
+
display: inline-block;
|
20
|
+
background: black;
|
21
|
+
color: #88FF88;
|
22
|
+
padding: 0.5rem;
|
23
|
+
border-radius: 0.5rem;
|
24
|
+
}
|
25
|
+
pre code {
|
26
|
+
border:none;
|
27
|
+
background-color: transparent;
|
28
|
+
padding: 0;
|
29
|
+
}
|
30
|
+
h1, h2, h3, h4, h5, h6 {
|
31
|
+
line-height: 1.2;
|
32
|
+
}
|
33
|
+
p {
|
34
|
+
line-height: 1.4;
|
35
|
+
max-width: 60ch;
|
36
|
+
}
|
37
|
+
.missing-page {
|
38
|
+
padding: 2rem;
|
39
|
+
width: 60ch;
|
40
|
+
margin-left: auto;
|
41
|
+
margin-right: auto;
|
42
|
+
border-radius: 1rem;
|
43
|
+
box-shadow: rgb(106, 106, 106) 2px 3px 6px 0px;
|
44
|
+
background: color-mix(in srgb, red, white 95%);
|
45
|
+
text-align: center;
|
46
|
+
}
|
47
|
+
.missing-page p {
|
48
|
+
text-align: left;
|
49
|
+
}
|
50
|
+
.missing-page h1 {
|
51
|
+
padding: 1rem;
|
52
|
+
border-radius: 1rem;
|
53
|
+
color: red;
|
54
|
+
display: inline-block;
|
55
|
+
background-color: white;
|
56
|
+
margin:0;
|
57
|
+
box-shadow: rgb(180, 180, 180) -1px -1px 5px 1px inset;
|
58
|
+
border: solid thin #ddd;
|
59
|
+
}
|
60
|
+
.missing-page pre {
|
61
|
+
box-shadow: rgb(106, 106, 106) 2px 3px 6px 0px;
|
62
|
+
}
|
63
|
+
</style>
|
64
|
+
</head>
|
65
|
+
<body>
|
66
|
+
<%= yield %>
|
67
|
+
</body>
|
68
|
+
</html>
|
@@ -1,7 +1,12 @@
|
|
1
1
|
module Brut::FrontEnd
|
2
|
+
# Base class of middlewares you can use in your app. This is currently a marker interface and provides no features
|
2
3
|
class Middleware
|
3
4
|
end
|
5
|
+
# Holds middlewares that are included with Brut and set up with all Brut apps by default
|
4
6
|
module Middlewares
|
7
|
+
autoload(:Favicon,"brut/front_end/middlewares/favicon")
|
5
8
|
autoload(:ReloadApp,"brut/front_end/middlewares/reload_app")
|
9
|
+
autoload(:AnnotateBrutOwnedPaths,"brut/front_end/middlewares/annotate_brut_owned_paths")
|
10
|
+
autoload(:OpenTelemetrySpan,"brut/front_end/middlewares/open_telemetry_span")
|
6
11
|
end
|
7
12
|
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# Annotates any path that is owned by Brut as such. Alleviates downstream code from having to include the actual
|
2
|
+
# path determination Brut uses. After this middleware has run, `env["brut.owned_path"]` will return `true` if the path
|
3
|
+
# represents one that Brut is managing.
|
4
|
+
class Brut::FrontEnd::Middlewares::AnnotateBrutOwnedPaths < Brut::FrontEnd::Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
def call(env)
|
9
|
+
if env["PATH_INFO"] =~ /^\/__brut\//
|
10
|
+
Brut.container.instrumentation.add_attributes("brut.owned_path" => true)
|
11
|
+
env["brut.owned_path"] = true
|
12
|
+
end
|
13
|
+
@app.call(env)
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# Handles requests for `/favicon.ico` by redirecting the browser to `/static/images/favicon.ico`.
|
2
|
+
class Brut::FrontEnd::Middlewares::Favicon < Brut::FrontEnd::Middleware
|
3
|
+
def initialize(app)
|
4
|
+
@app = app
|
5
|
+
end
|
6
|
+
def call(env)
|
7
|
+
if env["PATH_INFO"] =~ /^\/favicon.ico/
|
8
|
+
return [
|
9
|
+
301,
|
10
|
+
{ "location" => "/static/images/favicon.ico" },
|
11
|
+
[],
|
12
|
+
]
|
13
|
+
end
|
14
|
+
@app.call(env)
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
class Brut::FrontEnd::Middlewares::OpenTelemetrySpan < Brut::FrontEnd::Middleware
|
2
|
+
def initialize(app)
|
3
|
+
@app = app
|
4
|
+
end
|
5
|
+
def call(env)
|
6
|
+
path = env["REQUEST_PATH"]
|
7
|
+
method = env["REQUEST_METHOD"]
|
8
|
+
Brut.container.instrumentation.span("HTTP.#{method}.#{path}") do |span|
|
9
|
+
@app.call(env)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|