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.
- 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
|