brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. 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. Captures the overall state
2
- # of validity of an input. This can accomodate server-side constraint violations
3
- # that are essentially arbitrary. This means that an instance of this class should
4
- # fully capture all constraint violations for a given field. You can
5
- # iterate over all the violations with #each, which will yield one `ConstraintViolation` for
6
- # each failure. You can query the constraint to determine if it is a client side constraint or not.
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,value|
12
- if value
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 validation errors
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
- raise Brut::Framework::Errors::AbstractMethod
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
- SemanticLogger["brut:__brut/csp-reporting"].info(parsed)
7
+ Brut.container.instrumentation.add_attributes(parsed)
6
8
  rescue => ex
7
- SemanticLogger["brut:__brut/locale"].warn("Got #{ex} from /__brut/locale instead of a parseable JSON object")
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
- SemanticLogger["brut:__brut/locale"].info("Got #{parsed.class}/#{parsed}")
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
- SemanticLogger["brut:__brut/locale"].warn("Got #{ex} from /__brut/locale instead of a parseable JSON object")
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
- # For use inside handle! or process! to indicate the user should be redirected to
3
- # the route for the given class and query string parameters. If the route
4
- # does not support GET, an exception is raised
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
- # For use when an HTTP status code must be returned.
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
- def get? = self.to_sym == :get
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