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
@@ -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
- # unless there is a real concern that someone will pass the wrong type in and it would not be obvious
3
- # that they made this mistake. Of note, this is preferred for widely used classes instead of trying
4
- # to convert arguments to whatever type the class needs.
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:: the value that was given
9
- # type_descriptor:: a class or an array of allowed values. If a class, value must be kind_of? that class. If an array,
10
- # value must be one of the values in the array.
11
- # variable_name_for_error_message:: the name of the variable so that error messages make sense
12
- # required:: if true, the value may not be nil. If false, nil values are allowed and no real type checking is done. Note that a
13
- # string that is blank counts as nil, so a require string must not be blank.
14
- # coerce:: if given, this is the symbol that will be used to coerce the value before type checking
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 == "" )
@@ -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
- # Represents the Brut framework and its behavior for the app that requires it.
11
- # Essentially, this handles all default configuration and default setup behavior.
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
- i18n_locales_path = Brut.container.config_dir / "i18n"
41
- locales = Dir[i18n_locales_path / "*"].map { |_|
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[i18n_locales_path / "**/*.rb"]
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
- Brut.container.sequel_db_handle.disconnect
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
- [ Rack::Protection::AuthenticityToken, [ { allow_if: ->(env) { env["PATH_INFO"] =~ /^\/__brut\// } } ] ],
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
- hook_method.parameters.each do |(type,name)|
162
- if name.to_s == "**" || name.to_s == "*"
163
- 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"
164
- end
165
- if ![ :key,:keyreq ].include?(type)
166
- raise ArgumentError,"#{name} is not a keyword arg, but is a #{type}"
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
- if name == :request_context
170
- args[name] = Thread.current.thread_variable_get(:request_context)
171
- elsif name == :app_session
172
- args[name] = Brut.container.session_class.new(rack_session: session)
173
- elsif name == :request
174
- args[name] = request
175
- elsif name == :response
176
- args[name] = response
177
- elsif name == :env
178
- args[name] = env
179
- elsif type == :keyreq
180
- raise ArgumentError,"#{method} argument '#{name}' is required, but it's not available in a #{method} hook"
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
- # this keyword arg has a default value which will be used
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
 
@@ -1,4 +1,5 @@
1
1
  module Brut
2
+ # The Framework module holds a lot of Brut's internals, or classes that cut across the back end and front end.
2
3
  module Framework
3
4
  autoload(:App,"brut/framework/app")
4
5
  autoload(:Config,"brut/framework/config")
@@ -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(:Timestamp,"brut/front_end/components/timestamp")
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
- # The component has a few more smarts and helpers.
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
- } => component_instance
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
- request_context = Thread.current.thread_variable_get(:request_context).
128
- then { |request_context| request_context.as_method_args(component_instance,:render,request_params: nil, form: nil)
129
- }.then { |render_args| component_instance.render(**render_args).html_safe!
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
- # Render a form that should include CSRF protection.
145
- def form_tag(**attributes,&block)
146
- component(Brut::FrontEnd::Components::FormTag.new(**attributes,&block))
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
- def timestamp(timestamp, **component_options)
150
- component(Brut::FrontEnd::Components::Timestamp.new(**(component_options.merge(timestamp:))))
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|