brut 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/CODE_OF_CONDUCT.txt +99 -0
  4. data/Dockerfile.dx +32 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +133 -0
  7. data/LICENSE.txt +370 -0
  8. data/README.md +21 -0
  9. data/Rakefile +1 -0
  10. data/bin/bin_kit.rb +39 -0
  11. data/bin/rake +27 -0
  12. data/bin/setup +145 -0
  13. data/brut.gemspec +60 -0
  14. data/docker-compose.dx.yml +16 -0
  15. data/dx/build +26 -0
  16. data/dx/docker-compose.env +22 -0
  17. data/dx/dx.sh.lib +24 -0
  18. data/dx/exec +58 -0
  19. data/dx/prune +19 -0
  20. data/dx/setupkit.sh.lib +144 -0
  21. data/dx/show-help-in-app-container-then-wait.sh +38 -0
  22. data/dx/start +30 -0
  23. data/dx/stop +23 -0
  24. data/lib/brut/back_end/action.rb +3 -0
  25. data/lib/brut/back_end/result.rb +46 -0
  26. data/lib/brut/back_end/seed_data.rb +24 -0
  27. data/lib/brut/back_end/validator.rb +3 -0
  28. data/lib/brut/back_end/validators/form_validator.rb +37 -0
  29. data/lib/brut/cli/app.rb +130 -0
  30. data/lib/brut/cli/app_runner.rb +219 -0
  31. data/lib/brut/cli/apps/build_assets.rb +123 -0
  32. data/lib/brut/cli/apps/db.rb +279 -0
  33. data/lib/brut/cli/apps/scaffold.rb +256 -0
  34. data/lib/brut/cli/apps/test.rb +200 -0
  35. data/lib/brut/cli/command.rb +130 -0
  36. data/lib/brut/cli/error.rb +12 -0
  37. data/lib/brut/cli/execution_results.rb +81 -0
  38. data/lib/brut/cli/executor.rb +37 -0
  39. data/lib/brut/cli/options.rb +46 -0
  40. data/lib/brut/cli/output.rb +30 -0
  41. data/lib/brut/cli.rb +24 -0
  42. data/lib/brut/factory_bot.rb +20 -0
  43. data/lib/brut/framework/app.rb +55 -0
  44. data/lib/brut/framework/config.rb +415 -0
  45. data/lib/brut/framework/container.rb +190 -0
  46. data/lib/brut/framework/errors/abstract_method.rb +9 -0
  47. data/lib/brut/framework/errors/bug.rb +14 -0
  48. data/lib/brut/framework/errors/not_found.rb +10 -0
  49. data/lib/brut/framework/errors.rb +14 -0
  50. data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
  51. data/lib/brut/framework/mcp.rb +215 -0
  52. data/lib/brut/framework/project_environment.rb +18 -0
  53. data/lib/brut/framework.rb +13 -0
  54. data/lib/brut/front_end/asset_metadata.rb +76 -0
  55. data/lib/brut/front_end/component.rb +213 -0
  56. data/lib/brut/front_end/components/form_tag.rb +71 -0
  57. data/lib/brut/front_end/components/i18n_translations.rb +36 -0
  58. data/lib/brut/front_end/components/input.rb +13 -0
  59. data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
  60. data/lib/brut/front_end/components/inputs/select.rb +100 -0
  61. data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
  62. data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
  63. data/lib/brut/front_end/components/locale_detection.rb +25 -0
  64. data/lib/brut/front_end/components/page_identifier.rb +13 -0
  65. data/lib/brut/front_end/components/timestamp.rb +33 -0
  66. data/lib/brut/front_end/download.rb +23 -0
  67. data/lib/brut/front_end/flash.rb +57 -0
  68. data/lib/brut/front_end/form.rb +171 -0
  69. data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
  70. data/lib/brut/front_end/forms/input.rb +119 -0
  71. data/lib/brut/front_end/forms/input_definition.rb +100 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +36 -0
  73. data/lib/brut/front_end/handler.rb +48 -0
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
  75. data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
  76. data/lib/brut/front_end/handling_results.rb +14 -0
  77. data/lib/brut/front_end/http_method.rb +33 -0
  78. data/lib/brut/front_end/http_status.rb +16 -0
  79. data/lib/brut/front_end/middleware.rb +7 -0
  80. data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
  81. data/lib/brut/front_end/page.rb +47 -0
  82. data/lib/brut/front_end/request_context.rb +82 -0
  83. data/lib/brut/front_end/route_hook.rb +15 -0
  84. data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
  85. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
  86. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
  87. data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
  88. data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
  89. data/lib/brut/front_end/routing.rb +236 -0
  90. data/lib/brut/front_end/session.rb +56 -0
  91. data/lib/brut/front_end/template.rb +32 -0
  92. data/lib/brut/front_end/templates/block_filter.rb +60 -0
  93. data/lib/brut/front_end/templates/erb_engine.rb +26 -0
  94. data/lib/brut/front_end/templates/erb_parser.rb +84 -0
  95. data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
  96. data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
  97. data/lib/brut/i18n/base_methods.rb +168 -0
  98. data/lib/brut/i18n/for_cli.rb +4 -0
  99. data/lib/brut/i18n/for_html.rb +4 -0
  100. data/lib/brut/i18n/http_accept_language.rb +68 -0
  101. data/lib/brut/i18n.rb +6 -0
  102. data/lib/brut/instrumentation/basic.rb +66 -0
  103. data/lib/brut/instrumentation/event.rb +19 -0
  104. data/lib/brut/instrumentation/http_event.rb +5 -0
  105. data/lib/brut/instrumentation/subscriber.rb +41 -0
  106. data/lib/brut/instrumentation.rb +11 -0
  107. data/lib/brut/junk_drawer.rb +88 -0
  108. data/lib/brut/sinatra_helpers.rb +183 -0
  109. data/lib/brut/spec_support/component_support.rb +49 -0
  110. data/lib/brut/spec_support/flash_support.rb +7 -0
  111. data/lib/brut/spec_support/general_support.rb +18 -0
  112. data/lib/brut/spec_support/handler_support.rb +7 -0
  113. data/lib/brut/spec_support/matcher.rb +9 -0
  114. data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
  115. data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
  116. data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
  117. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
  118. data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
  119. data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
  120. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
  121. data/lib/brut/spec_support/session_support.rb +3 -0
  122. data/lib/brut/spec_support.rb +12 -0
  123. data/lib/brut/version.rb +3 -0
  124. data/lib/brut.rb +38 -0
  125. data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
  126. data/lib/sequel/extensions/brut_migrations.rb +98 -0
  127. data/lib/sequel/plugins/created_at.rb +14 -0
  128. data/lib/sequel/plugins/external_id.rb +45 -0
  129. data/lib/sequel/plugins/find_bang.rb +13 -0
  130. data/lib/sequel/plugins.rb +3 -0
  131. metadata +484 -0
@@ -0,0 +1,168 @@
1
+ # Interface for translations. This is prefered over using Ruby's I18n directly.
2
+ # This is intended to be mixed-in to any class that requires this, so that you can more
3
+ # expediently access the `t` method.
4
+ module Brut::I18n::BaseMethods
5
+
6
+ # Access a translation and insert interpolated elemens as needed. This will use the provided key to determine
7
+ # the actual full key to the translation, as described below. The value returned is not HTML escaped,
8
+ # assuming that you have not placed HTML injections in your own translation files. Interpolated
9
+ # values *are* HTML escaped, so external input is safe to provide.
10
+ #
11
+ # This method also may take a block, and the results of the block are inserted into the `%{block}`
12
+ # interpolation value in the i18n string, if it's present.
13
+ #
14
+ # Any missing interpolation will result in an exception, *except* for the value `field`. When
15
+ # a string has `%{field}` in it, but `field:` is omitted in this call, the value for
16
+ # `"general.cv.this_field"` is used. This value, in English, is "this field", so a call
17
+ # to `t("email.required")` would generate `"This field is required"`, while a call
18
+ # to `t("email.required", field: "E-mail address")` would generate `"E-mail address is required"`.
19
+ #
20
+ # @param [String,Symbol,Array<String>,Array<Symbol>] key used to create one or more keys to be translated.
21
+ # This value's behavior is designed to a balance predictabilitiy in what actual key is chosen
22
+ # but without needless repetition on a page. If this value is provided, and is an array, the values
23
+ # are joined with "." to form a key. If the value is not an array, that value is used directly.
24
+ # Given this key, two values are checked for a translation: the key itself and
25
+ # the key inside "general.". If this value is *not* provided, it is expected
26
+ # taht the `**rest` hash includes page: or component:. See that parameter and the example.
27
+ #
28
+ # @param [Hash] rest values to use for interpolation of the key's translation. If `key` is omitted,
29
+ # this hash should have a value for either `page:` or `component:` (not both). If
30
+ # `page:` is present, it is assumed that the class that has included this module
31
+ # is a `Brut::FrontEnd::Page` or is a page component. It's `page_name` will be used to create
32
+ # a key based on the value of `page:`: `pages.«page_name».«page: value»`.
33
+ # if `component:` is included, the behavior is the same but for `component` instead of `page`.
34
+ # @option interpolated_values [Numeric] count Special interpolation to control pluralization.
35
+ #
36
+ # @raise [I18n::MissingTranslation] if no translation is found
37
+ # @raise [I18n::MissingInterpolationArgument] if interpolation arguments are missing, or if the key
38
+ # has pluralizations and no count: was given
39
+ #
40
+ # @example Simplest usage
41
+ # # in your translations file
42
+ # en: {
43
+ # general: {
44
+ # hello: "Hi!"
45
+ # },
46
+ # formalized: {
47
+ # hello: "Greetings!"
48
+ # }
49
+ # }
50
+ # # in your code
51
+ # t(:hello) # => Hi!
52
+ # t("formalized.hello") # => Greetings!
53
+ #
54
+ # @example Using an array for the key
55
+ # # in your translations file
56
+ # en: {
57
+ # general: {
58
+ # actions: {
59
+ # edit: "Make an edit"
60
+ # }
61
+ # },
62
+ # }
63
+ # # in your code
64
+ # t([:actions, :edit]) # => Make an edit
65
+ #
66
+ # @example Using page:
67
+ # # in your translations file
68
+ # en: {
69
+ # pages: {
70
+ # HomePage: {
71
+ # new_widget: "Create new Widget"
72
+ # },
73
+ # WidgetsPage: {
74
+ # new_widget: "Create New"
75
+ # },
76
+ # },
77
+ # }
78
+ # # in your code for HomePage
79
+ # t(page: :new_widget) # => Create new Widget
80
+ # # in your code for WidgetsPage
81
+ # t(page: :new_widget) # => Create New
82
+ #
83
+ # @example Using page: with an array
84
+ # # in your translations file
85
+ # en: {
86
+ # pages: {
87
+ # WidgetsPage: {
88
+ # new_widget: "Create New"
89
+ # captions: {
90
+ # new: "New Widgets"
91
+ # }
92
+ # },
93
+ # },
94
+ # }
95
+ # # in your code for HomePage
96
+ # t(page: [ :captions, :new ]) # => New Widgets
97
+ def t(key=:look_in_rest,**rest)
98
+ if key == :look_in_rest
99
+
100
+ page = rest.delete(:page)
101
+ component = rest.delete(:component)
102
+
103
+ if !page.nil? && !component.nil?
104
+ raise ArgumentError, "You may only specify page or component, not both"
105
+ end
106
+
107
+ if page
108
+ key = ["pages.#{self.page_name}.#{Array(page).join('.')}"]
109
+ elsif component
110
+ key = ["components.#{self.component_name}.#{Array(component).join('.')}"]
111
+ else
112
+ raise ArgumentError, "If you omit an explicit key, you must specify page or component"
113
+ end
114
+ else
115
+ key = Array(key).join('.')
116
+ key = [key,"general.#{key}"]
117
+ end
118
+ if block_given?
119
+ if rest[:block]
120
+ raise ArgumentError,"t was given a block and a block: param. You can't do both "
121
+ end
122
+ rest[:block] = html_safe(yield.to_s.strip)
123
+ end
124
+ html_safe(t_direct(key,**rest))
125
+ rescue I18n::MissingInterpolationArgument => ex
126
+ if ex.key.to_s == "block"
127
+ raise ArgumentError,"One of the keys #{key.join(", ")} contained a %{block} interpolation value: '#{ex.string}'. This means you must use t_html *and* yield a block to it"
128
+ else
129
+ raise
130
+ end
131
+ end
132
+
133
+ def this_field_value
134
+ @__this_field_value ||= ::I18n.t("general.cv.this_field", raise: true)
135
+ end
136
+
137
+ # Directly access translations without trying to be smart about deriving the key. This is useful
138
+ # if you have the exact keys you want.
139
+ #
140
+ # @param [Array<String>,Array<Symbol>] keys list of keys representing what is to be translated. The
141
+ # first key found will be used. If no key in the list is found
142
+ # will raise a I18n::MissingTranslation
143
+ # @param [Hash] interpolated_values value to use for interpolation of the key's translation
144
+ # @option interpolated_values [Numeric] count Special interpolation to control pluralization.
145
+ #
146
+ # @raise [I18n::MissingTranslation] if no translation is found
147
+ # @raise [I18n::MissingInterpolationArgument] if interpolation arguments are missing, or if the key
148
+ # has pluralizations and no count: was given
149
+ def t_direct(keys,interpolated_values={})
150
+ keys = Array(keys).map(&:to_sym)
151
+ default_interpolated_values = {
152
+ field: this_field_value,
153
+ }
154
+ escaped_interpolated_values = interpolated_values.map { |key,value|
155
+ if value.kind_of?(String)
156
+ [ key, Brut::FrontEnd::Template.escape_html(value) ]
157
+ else
158
+ [ key, value ]
159
+ end
160
+ }.to_h
161
+ result = ::I18n.t(keys.first, default: keys[1..-1],raise: true, **default_interpolated_values.merge(escaped_interpolated_values))
162
+ if result.kind_of?(Hash)
163
+ raise I18n::MissingInterpolationArgument.new(:count,interpolated_values,keys.join(","))
164
+ end
165
+ result
166
+ end
167
+
168
+ end
@@ -0,0 +1,4 @@
1
+ module Brut::I18n::ForCLI
2
+ include Brut::I18n::BaseMethods
3
+ def html_safe(string) = string
4
+ end
@@ -0,0 +1,4 @@
1
+ module Brut::I18n::ForHTML
2
+ include Brut::I18n::BaseMethods
3
+ def html_safe(string) = Brut::FrontEnd::Templates::HTMLSafeString.from_string(string)
4
+ end
@@ -0,0 +1,68 @@
1
+ class Brut::I18n::HTTPAcceptLanguage
2
+ WeightedLocale = Data.define(:locale, :q) do
3
+ def primary_locale = self.locale.gsub(/\-.*$/,"")
4
+ def primary? = self.primary_locale == self.locale
5
+
6
+ def primary_only
7
+ self.class.new(locale: self.primary_locale, q: self.q)
8
+ end
9
+
10
+ def ==(other)
11
+ self.locale == other.locale
12
+ end
13
+ end
14
+
15
+ def self.from_session(session_value)
16
+ values = session_value.to_s.split(/,/).map { |value|
17
+ locale,q = value.split(/;/)
18
+ WeightedLocale.new(locale:,q:)
19
+ }
20
+ if values.any?
21
+ self.new(values)
22
+ else
23
+ AlwaysEnglish.new
24
+ end
25
+ end
26
+
27
+ def self.from_browser(value)
28
+ value = value.to_s.strip
29
+ if value == ""
30
+ AlwaysEnglish.new
31
+ else
32
+ self.new([ WeightedLocale.new(locale: value, q: 1) ])
33
+ end
34
+ end
35
+
36
+ def self.from_header(header_value)
37
+ header_value = header_value.to_s.strip
38
+ if header_value == "*" || header_value == ""
39
+ AlwaysEnglish.new
40
+ else
41
+ values = header_value.split(/,/).map(&:strip).map { |language|
42
+ locale,q = language.split(/;\s*q\s*=\s*/,2)
43
+ WeightedLocale.new(locale: locale,q: q.nil? ? 1 : q.to_f)
44
+ }
45
+ if values.any?
46
+ self.new(values)
47
+ else
48
+ AlwaysEnglish.new
49
+ end
50
+ end
51
+ end
52
+
53
+ attr_reader :weighted_locales
54
+ def initialize(weighted_locales)
55
+ @weighted_locales = weighted_locales.sort_by(&:q).reverse
56
+ end
57
+ def known? = true
58
+ def for_session = @weighted_locales.map { |weighted_locale| "#{weighted_locale.locale};#{weighted_locale.q}" }.join(",")
59
+ def to_s = self.for_session
60
+
61
+ class AlwaysEnglish < Brut::I18n::HTTPAcceptLanguage
62
+ def initialize
63
+ super([ WeightedLocale.new(locale: "en", q: 1) ])
64
+ end
65
+ def known? = false
66
+ end
67
+
68
+ end
data/lib/brut/i18n.rb ADDED
@@ -0,0 +1,6 @@
1
+ module Brut::I18n
2
+ autoload(:BaseMethods, "brut/i18n/base_methods")
3
+ autoload(:ForCLI, "brut/i18n/for_cli")
4
+ autoload(:ForHTML, "brut/i18n/for_html")
5
+ autoload(:HTTPAcceptLanguage, "brut/i18n/http_accept_language")
6
+ end
@@ -0,0 +1,66 @@
1
+ class Brut::Instrumentation::Basic
2
+ def initialize
3
+ @subscribers = Concurrent::Set.new
4
+ end
5
+
6
+ class TypeChecking < Brut::Instrumentation::Basic
7
+ def instrument(event,&block)
8
+ if !event.kind_of?(Brut::Instrumentation::Event)
9
+ raise "You cannot instrument a #{event.class} - it must be a Brut::Instrumentation::Event or subclass"
10
+ end
11
+ super
12
+ end
13
+ end
14
+
15
+ def instrument(event,&block)
16
+ block ||= ->() {}
17
+
18
+ start = Time.now
19
+ result = nil
20
+ exception = nil
21
+
22
+ begin
23
+ result = block.(event)
24
+ rescue => ex
25
+ exception = ex
26
+ end
27
+ stop = Time.now
28
+ notify(event:,start:,stop:,exception:)
29
+ if exception
30
+ raise exception
31
+ else
32
+ result
33
+ end
34
+ end
35
+
36
+ def subscribe(subscriber=:use_block,&block)
37
+ if block.nil? && subscriber == :use_block
38
+ raise ArgumentError,"subscriber requires a Brut::Instrumentation::Subscriber or a block"
39
+ end
40
+ if !block.nil? && subscriber != :use_block
41
+ raise ArgumentError,"subscriber requires a Brut::Instrumentation::Subscriber or a block, not both"
42
+ end
43
+ if block.nil?
44
+ if subscriber.kind_of?(Proc)
45
+ subscriber = Brut::Instrumentation::Subscriber.from_proc(subscriber)
46
+ elsif !subscriber.kind_of?(Brut::Instrumentation::Subscriber)
47
+ raise ArgumentError, "subscriber must be a Proc or Brut::Instrumentation::Subscriber, not a #{subscriber.class}"
48
+ end
49
+ else
50
+ subscriber = Brut::Instrumentation::Subscriber.from_proc(block)
51
+ end
52
+ @subscribers << subscriber
53
+ end
54
+
55
+ def notify(event:,start:,stop:,exception:)
56
+ Thread.new do
57
+ @subscribers.each do |subscriber|
58
+ begin
59
+ subscriber.(event:,start:,stop:,exception:)
60
+ rescue => ex
61
+ warn "#{subscriber} raised #{ex}"
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,19 @@
1
+ class Brut::Instrumentation::Event
2
+ include Brut::Framework::FussyTypeEnforcement
3
+
4
+ attr_reader :category,
5
+ :subcategory,
6
+ :name,
7
+ :details
8
+
9
+ def initialize(category:,
10
+ subcategory:nil,
11
+ name:,
12
+ details:{})
13
+ @category = type!(category,String,"category",required: true, coerce: :to_s)
14
+ @subcategory = type!(subcategory,String,"subcategory",required: false, coerce: :to_s)
15
+ @name = type!(name,String,"name",required:true,coerce: :to_s)
16
+ @details = type!(details,Hash,"details",required:false) || {}
17
+ end
18
+
19
+ end
@@ -0,0 +1,5 @@
1
+ class Brut::Instrumentation::HTTPEvent < Brut::Instrumentation::Event
2
+ def initialize(http_method:,name:,path:,details:{})
3
+ super(category: "http", subcategory: http_method, name: name, details: details.merge(path:path))
4
+ end
5
+ end
@@ -0,0 +1,41 @@
1
+ class Brut::Instrumentation::Subscriber
2
+ def self.from_proc(block)
3
+ required_parameter_names_found = self.instance_method(:call).parameters.map { |(type,name)| [ name, false ] }.to_h
4
+ unexpected_parameter_names_error = {}
5
+
6
+ block.parameters.each do |(type,name)|
7
+ if required_parameter_names_found.key?(name)
8
+ if type == :key || type == :keyreq
9
+ required_parameter_names_found[name] = true
10
+ else
11
+ unexpected_parameter_names[name] = "Not a keyword arg"
12
+ end
13
+ elsif type != :key
14
+ if type == :keyreq
15
+ unexpected_parameter_names[name] = "keyword arg without a default value"
16
+ else
17
+ unexpected_parameter_names[name] = "Not a keyword arg"
18
+ end
19
+ end
20
+ end
21
+ errors = []
22
+ if unexpected_parameter_names_error.any?
23
+ messages = unexpected_parameter_names_error.map { |name,problem|
24
+ "#{name} - #{problem}"
25
+ }.join(", ")
26
+ errors << "Unexpected parameters were required, so this cannot be used as a subscriber: #{messages}"
27
+ end
28
+ if required_parameter_names_found.any? { |_name,found| !found }
29
+ messages = required_parameter_names_found.select { |_name,found| !found }.map { |name,_found| "#{name} must be a keyword argument" }.join(",")
30
+ errors << "Required parameters were missing, so this cannot be used as a subscriber: #{messages}"
31
+ end
32
+ if errors.any?
33
+ raise ArgumentError,errors.join(", ")
34
+ end
35
+ block
36
+ end
37
+
38
+ def call(event:,start:,stop:,exception:)
39
+ end
40
+
41
+ end
@@ -0,0 +1,11 @@
1
+ module Brut::Instrumentation
2
+ autoload(:Basic,"brut/instrumentation/basic")
3
+ autoload(:Subscriber,"brut/instrumentation/subscriber")
4
+ autoload(:Event,"brut/instrumentation/event")
5
+ autoload(:HTTPEvent,"brut/instrumentation/http_event")
6
+
7
+ def instrument(**args,&block)
8
+ Brut.container.instrumentation.instrument(Brut::Instrumentation::Event.new(**args),&block)
9
+ end
10
+ end
11
+
@@ -0,0 +1,88 @@
1
+ require "tzinfo"
2
+ class Clock
3
+ def initialize(tzinfo_timezone)
4
+ if tzinfo_timezone
5
+ @timezone = tzinfo_timezone
6
+ elsif ENV["TZ"]
7
+ @timezone = begin
8
+ TZInfo::Timezone.get(ENV["TZ"])
9
+ rescue TZInfo::InvalidTimezoneIdentifier => ex
10
+ SemanticLogger[self.class.name].warn("#{ex} from ENV['TZ'] value '#{ENV['TZ']}'")
11
+ nil
12
+ end
13
+ end
14
+ if @timezone.nil?
15
+ @timezone = TZInfo::Timezone.get("UTC")
16
+ end
17
+ end
18
+
19
+ def now
20
+ Time.now(in: @timezone)
21
+ end
22
+
23
+ def in_time_zone(time)
24
+ @timezone.to_local(time)
25
+ end
26
+ end
27
+
28
+ class RichString
29
+ def initialize(string)
30
+ @string = string.to_s
31
+ end
32
+
33
+ def underscorized
34
+ return self unless /[A-Z-]|::/.match?(@string)
35
+ word = @string.gsub("::", "/")
36
+ word.gsub!(/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[a-z\d])(?=[A-Z])/, "_")
37
+ word.tr!("-", "_")
38
+ word.downcase!
39
+ RichString.new(word)
40
+ end
41
+
42
+ def camelize
43
+ @string.to_s.split(/[_-]/).map { |part|
44
+ part.capitalize
45
+ }.join("")
46
+ end
47
+
48
+ def humanized
49
+ RichString.new(@string.tr("_-"," "))
50
+ end
51
+
52
+ def to_s = @string
53
+ def to_str = self.to_s
54
+
55
+ def to_s_or_nil = @string.empty? ? nil : self.to_s
56
+
57
+ def ==(other)
58
+ if other.kind_of?(RichString)
59
+ self.to_s == other.to_s
60
+ elsif other.kind_of?(String)
61
+ self.to_s == other
62
+ else
63
+ false
64
+ end
65
+ end
66
+
67
+ def <=>(other)
68
+ if other.kind_of?(RichString)
69
+ self.to_s <=> other.to_s
70
+ elsif other.kind_of?(String)
71
+ self.to_s <=> other
72
+ else
73
+ super
74
+ end
75
+ end
76
+
77
+ def +(other)
78
+ if other.kind_of?(RichString)
79
+ RichString.new(self.to_s + other.to_s)
80
+ elsif other.kind_of?(String)
81
+ self.to_s + other
82
+ else
83
+ super(other)
84
+ end
85
+ end
86
+
87
+ end
88
+
@@ -0,0 +1,183 @@
1
+ module Brut::SinatraHelpers
2
+
3
+ def self.included(sinatra_app)
4
+ sinatra_app.extend(ClassMethods)
5
+
6
+ sinatra_app.set :logging, false
7
+ sinatra_app.set :public_folder, Brut.container.public_root_dir
8
+ sinatra_app.path("/__brut/csp-reporting",method: :post)
9
+ sinatra_app.path("/__brut/locale_detection",method: :post)
10
+ end
11
+
12
+ # @private
13
+ def render_html(component_or_page_instance)
14
+ result = component_or_page_instance.render
15
+ case result
16
+ in Brut::FrontEnd::HttpStatus => http_status
17
+ http_status.to_i
18
+ else
19
+ result
20
+ end
21
+ end
22
+
23
+
24
+ module ClassMethods
25
+
26
+ # Regsiters a page in your app. A page is what it sounds like - a web page that's rendered from a URL. It will be provided
27
+ # via an HTTP get to the path provided.
28
+ #
29
+ # The page is rendered dynamically by using an instance of a page class as binding to HTML via ERB. The name of the class and the name of the
30
+ # ERB file are based on the path, according to the conventions described below.
31
+ #
32
+ # A few examples:
33
+ #
34
+ # * `page("/widgets")` will use `WidgetsPage`, and expect the HTML in `app/src/pages/widgets_page.html.erb`
35
+ # * `page("/widgets/:id")` will use `WidgetsByIdPage`, and expect the HTML in `app/src/pages/widgets_by_id_page.html.erb`
36
+ # * `page("/admin/widgets/:internal_id") will use `Admin::WidgetsByInternalIdPage`, and expect HTML in
37
+ # `app/src/pages/admin/widgets_by_internal_id_page.html.erb`
38
+ #
39
+ # The general conventions are:
40
+ #
41
+ # * Each part of the path that is not a placeholder will be camelized
42
+ # * Any part of the path that *is* a placholder has its leading colon removed, then is camelized, but appended to
43
+ # the previous part with `By`, thus `WidgetsById` is created from `Widgets`, `By`, and `Id`.
44
+ # * The final part of the path is further appended with `Page`.
45
+ # * These parts now make up a path to a class, so the entire thing is joined by `::` to form the fully-qualified class name.
46
+ #
47
+ # When a GET is issued to the path, the page is instantiated. The page's constructor may accept keyword arguments (however it must not accept
48
+ # any other type of argument).
49
+ #
50
+ # Each keyword argument found will be provided when the class is created, as follows:
51
+ #
52
+ # * Any placeholders, so when a path `/widgets/1234` is requested, `WidgetsPage.new(id: "1234")` will be used to create the page object.
53
+ # * Anything in the request context, such as the current user
54
+ # * Any query string parameters
55
+ # * Anything passed as keyword args to this method, with the following adjustment:
56
+ # - Any key ending in `_class` whose value is a Class will be instantiated and
57
+ # passed in as the key withoutr `_class`, e.g. form_class: SomeForm will
58
+ # pass `form: SomeForm.new` to the constructor
59
+ # * The flash
60
+ #
61
+ # Once this page object exists, `render` will be called to produce HTML to send back to the browser.
62
+ def page(path)
63
+ Brut.container.routing.register_page(path)
64
+
65
+ get path do
66
+ Brut.container.instrumentation.instrument(Brut::Instrumentation::HTTPEvent.new(name: :get_page, http_method: "GET", path: path )) do
67
+ route = Brut.container.routing.for(path: path,method: :get)
68
+ page_class = route.handler_class
69
+ request_context = Thread.current.thread_variable_get(:request_context)
70
+ constructor_args = request_context.as_constructor_args(
71
+ page_class,
72
+ request_params: params,
73
+ )
74
+ page_instance = page_class.new(**constructor_args)
75
+ result = page_instance.handle!
76
+ case result
77
+ in URI => uri
78
+ redirect to(uri.to_s)
79
+ in Brut::FrontEnd::HttpStatus => http_status
80
+ http_status.to_i
81
+ else
82
+ result
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # Declares a form that will be submitted to the app. To handle the submission you must providate
89
+ # a handler and an optional form. The form defines all the fields in your form, including constraints.
90
+ # These can be used to generate HTML for the form. When the form is submitted to your app, the form
91
+ # is instantiated and filled in with all the values it is requesting. That form is then passed off to the
92
+ # configured handler. The handle! method performs whatever processing is needed.
93
+ #
94
+ # If you have no form elements and are just responding to a POST action from a browser, use `action`.
95
+ #
96
+ # The name of the classes are based on a convention similar to `page`:
97
+ #
98
+ # * Each part of the path that is not a placeholder will be camelized
99
+ # * Any part of the path that *is* a placholder has its leading colon removed, then is camelized, but appended to
100
+ # the previous part with `With`, thus `WidgetsWithId` is created from `Widgets`, `With`, and `Id`.
101
+ # * The final part of the path is further appended with `Form` or `Handler`.
102
+ # * These parts now make up a path to a class, so the entire thing is joined by `::` to form the fully-qualified class name.
103
+ #
104
+ # Examples:
105
+ #
106
+ # * `form("/widgets")` will use `WidgetsForm` and `WidgetsHandler`
107
+ # * `form("/widgets/:id")` will use `WidgetsWithIdForm` and `WidgetsWithIdHandler`
108
+ # * `form("/admin/widgets/:internal_id") will use `Admin::WidgetsWithInternalIdForm` and `Admin::WidgetsWithInternalIdHandler`
109
+ #
110
+ def form(path)
111
+ route = Brut.container.routing.register_form(path)
112
+ self.define_handled_route(route, type: :form)
113
+ end
114
+
115
+ # Declare a form action that has no associated form elements. This is used when you need to use a button to submit to the
116
+ # back-end, and the route contains all the context you need. For example a post to `/approved_widgets/:id` communicates that the
117
+ # Widget with ID `:id` can be approved.
118
+ #
119
+ # This is preferred over `path` because a) it's more explicit that this is handling a POST from some HTML and b) this will check
120
+ # to make sure there is no form defined.
121
+ def action(path)
122
+ route = Brut.container.routing.register_handler_only(path)
123
+ self.define_handled_route(route, type: :action)
124
+ end
125
+
126
+ # When you need to respond to a given path/method, but it's not a page nor a form. For example, webhooks often
127
+ # require responding to GET even though they aren't rendering pages nor considered to be idempotent.
128
+ #
129
+ # This will locate a handler class based on the same naming convention as for forms.
130
+ def path(path, method:)
131
+ route = Brut.container.routing.register_path(path, method: Brut::FrontEnd::HttpMethod.new(method))
132
+ self.define_handled_route(route,type: :generic)
133
+ end
134
+
135
+ private
136
+
137
+ def define_handled_route(original_brut_route,type:)
138
+
139
+ method = original_brut_route.http_method.to_s.upcase
140
+ path = original_brut_route.path_template
141
+
142
+ route method, path do
143
+ Brut.container.instrumentation.instrument(Brut::Instrumentation::HTTPEvent.new(name: type, http_method: method, path: path)) do
144
+ brut_route = Brut.container.routing.for(path:,method:)
145
+
146
+ handler_class = brut_route.handler_class
147
+ form_class = brut_route.respond_to?(:form_class) ? brut_route.form_class : nil
148
+
149
+ request_context = Thread.current.thread_variable_get(:request_context)
150
+ handler = handler_class.new
151
+ form = if form_class.nil?
152
+ nil
153
+ else
154
+ form_class.new(params: params)
155
+ end
156
+
157
+ process_args = request_context.as_method_args(handler,:handle,request_params: params,form: form)
158
+
159
+ result = handler.handle!(**process_args)
160
+
161
+ case result
162
+ in URI => uri
163
+ redirect to(uri.to_s)
164
+ in Brut::FrontEnd::Component => component_instance
165
+ render_html(component_instance).to_s
166
+ in [ Brut::FrontEnd::Component => component_instance, Brut::FrontEnd::HttpStatus => http_status ]
167
+ [
168
+ http_status.to_i,
169
+ render_html(component_instance).to_s,
170
+ ]
171
+ in Brut::FrontEnd::HttpStatus => http_status
172
+ http_status.to_i
173
+ in Brut::FrontEnd::Download => download
174
+ [ 200, download.headers, download.data ]
175
+ else
176
+ raise NoMatchingPatternError, "Result from #{handler.class}'s handle! method was a #{result.class}, which cannot be used to understand the response to generate"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
182
+ end
183
+ end