brut 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +7 -0
- data/CODE_OF_CONDUCT.txt +99 -0
- data/Dockerfile.dx +32 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +133 -0
- data/LICENSE.txt +370 -0
- data/README.md +21 -0
- data/Rakefile +1 -0
- data/bin/bin_kit.rb +39 -0
- data/bin/rake +27 -0
- data/bin/setup +145 -0
- data/brut.gemspec +60 -0
- data/docker-compose.dx.yml +16 -0
- data/dx/build +26 -0
- data/dx/docker-compose.env +22 -0
- data/dx/dx.sh.lib +24 -0
- data/dx/exec +58 -0
- data/dx/prune +19 -0
- data/dx/setupkit.sh.lib +144 -0
- data/dx/show-help-in-app-container-then-wait.sh +38 -0
- data/dx/start +30 -0
- data/dx/stop +23 -0
- data/lib/brut/back_end/action.rb +3 -0
- data/lib/brut/back_end/result.rb +46 -0
- data/lib/brut/back_end/seed_data.rb +24 -0
- data/lib/brut/back_end/validator.rb +3 -0
- data/lib/brut/back_end/validators/form_validator.rb +37 -0
- data/lib/brut/cli/app.rb +130 -0
- data/lib/brut/cli/app_runner.rb +219 -0
- data/lib/brut/cli/apps/build_assets.rb +123 -0
- data/lib/brut/cli/apps/db.rb +279 -0
- data/lib/brut/cli/apps/scaffold.rb +256 -0
- data/lib/brut/cli/apps/test.rb +200 -0
- data/lib/brut/cli/command.rb +130 -0
- data/lib/brut/cli/error.rb +12 -0
- data/lib/brut/cli/execution_results.rb +81 -0
- data/lib/brut/cli/executor.rb +37 -0
- data/lib/brut/cli/options.rb +46 -0
- data/lib/brut/cli/output.rb +30 -0
- data/lib/brut/cli.rb +24 -0
- data/lib/brut/factory_bot.rb +20 -0
- data/lib/brut/framework/app.rb +55 -0
- data/lib/brut/framework/config.rb +415 -0
- data/lib/brut/framework/container.rb +190 -0
- data/lib/brut/framework/errors/abstract_method.rb +9 -0
- data/lib/brut/framework/errors/bug.rb +14 -0
- data/lib/brut/framework/errors/not_found.rb +10 -0
- data/lib/brut/framework/errors.rb +14 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
- data/lib/brut/framework/mcp.rb +215 -0
- data/lib/brut/framework/project_environment.rb +18 -0
- data/lib/brut/framework.rb +13 -0
- data/lib/brut/front_end/asset_metadata.rb +76 -0
- data/lib/brut/front_end/component.rb +213 -0
- data/lib/brut/front_end/components/form_tag.rb +71 -0
- data/lib/brut/front_end/components/i18n_translations.rb +36 -0
- data/lib/brut/front_end/components/input.rb +13 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
- data/lib/brut/front_end/components/inputs/select.rb +100 -0
- data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
- data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
- data/lib/brut/front_end/components/locale_detection.rb +25 -0
- data/lib/brut/front_end/components/page_identifier.rb +13 -0
- data/lib/brut/front_end/components/timestamp.rb +33 -0
- data/lib/brut/front_end/download.rb +23 -0
- data/lib/brut/front_end/flash.rb +57 -0
- data/lib/brut/front_end/form.rb +171 -0
- data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
- data/lib/brut/front_end/forms/input.rb +119 -0
- data/lib/brut/front_end/forms/input_definition.rb +100 -0
- data/lib/brut/front_end/forms/validity_state.rb +36 -0
- data/lib/brut/front_end/handler.rb +48 -0
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
- data/lib/brut/front_end/handling_results.rb +14 -0
- data/lib/brut/front_end/http_method.rb +33 -0
- data/lib/brut/front_end/http_status.rb +16 -0
- data/lib/brut/front_end/middleware.rb +7 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
- data/lib/brut/front_end/page.rb +47 -0
- data/lib/brut/front_end/request_context.rb +82 -0
- data/lib/brut/front_end/route_hook.rb +15 -0
- data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
- data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
- data/lib/brut/front_end/routing.rb +236 -0
- data/lib/brut/front_end/session.rb +56 -0
- data/lib/brut/front_end/template.rb +32 -0
- data/lib/brut/front_end/templates/block_filter.rb +60 -0
- data/lib/brut/front_end/templates/erb_engine.rb +26 -0
- data/lib/brut/front_end/templates/erb_parser.rb +84 -0
- data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
- data/lib/brut/i18n/base_methods.rb +168 -0
- data/lib/brut/i18n/for_cli.rb +4 -0
- data/lib/brut/i18n/for_html.rb +4 -0
- data/lib/brut/i18n/http_accept_language.rb +68 -0
- data/lib/brut/i18n.rb +6 -0
- data/lib/brut/instrumentation/basic.rb +66 -0
- data/lib/brut/instrumentation/event.rb +19 -0
- data/lib/brut/instrumentation/http_event.rb +5 -0
- data/lib/brut/instrumentation/subscriber.rb +41 -0
- data/lib/brut/instrumentation.rb +11 -0
- data/lib/brut/junk_drawer.rb +88 -0
- data/lib/brut/sinatra_helpers.rb +183 -0
- data/lib/brut/spec_support/component_support.rb +49 -0
- data/lib/brut/spec_support/flash_support.rb +7 -0
- data/lib/brut/spec_support/general_support.rb +18 -0
- data/lib/brut/spec_support/handler_support.rb +7 -0
- data/lib/brut/spec_support/matcher.rb +9 -0
- data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
- data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
- data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
- data/lib/brut/spec_support/session_support.rb +3 -0
- data/lib/brut/spec_support.rb +12 -0
- data/lib/brut/version.rb +3 -0
- data/lib/brut.rb +38 -0
- data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
- data/lib/sequel/extensions/brut_migrations.rb +98 -0
- data/lib/sequel/plugins/created_at.rb +14 -0
- data/lib/sequel/plugins/external_id.rb +45 -0
- data/lib/sequel/plugins/find_bang.rb +13 -0
- data/lib/sequel/plugins.rb +3 -0
- 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,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,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,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
|