brut 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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