brut 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/CODE_OF_CONDUCT.txt +99 -0
  4. data/Dockerfile.dx +32 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +133 -0
  7. data/LICENSE.txt +370 -0
  8. data/README.md +21 -0
  9. data/Rakefile +1 -0
  10. data/bin/bin_kit.rb +39 -0
  11. data/bin/rake +27 -0
  12. data/bin/setup +145 -0
  13. data/brut.gemspec +60 -0
  14. data/docker-compose.dx.yml +16 -0
  15. data/dx/build +26 -0
  16. data/dx/docker-compose.env +22 -0
  17. data/dx/dx.sh.lib +24 -0
  18. data/dx/exec +58 -0
  19. data/dx/prune +19 -0
  20. data/dx/setupkit.sh.lib +144 -0
  21. data/dx/show-help-in-app-container-then-wait.sh +38 -0
  22. data/dx/start +30 -0
  23. data/dx/stop +23 -0
  24. data/lib/brut/back_end/action.rb +3 -0
  25. data/lib/brut/back_end/result.rb +46 -0
  26. data/lib/brut/back_end/seed_data.rb +24 -0
  27. data/lib/brut/back_end/validator.rb +3 -0
  28. data/lib/brut/back_end/validators/form_validator.rb +37 -0
  29. data/lib/brut/cli/app.rb +130 -0
  30. data/lib/brut/cli/app_runner.rb +219 -0
  31. data/lib/brut/cli/apps/build_assets.rb +123 -0
  32. data/lib/brut/cli/apps/db.rb +279 -0
  33. data/lib/brut/cli/apps/scaffold.rb +256 -0
  34. data/lib/brut/cli/apps/test.rb +200 -0
  35. data/lib/brut/cli/command.rb +130 -0
  36. data/lib/brut/cli/error.rb +12 -0
  37. data/lib/brut/cli/execution_results.rb +81 -0
  38. data/lib/brut/cli/executor.rb +37 -0
  39. data/lib/brut/cli/options.rb +46 -0
  40. data/lib/brut/cli/output.rb +30 -0
  41. data/lib/brut/cli.rb +24 -0
  42. data/lib/brut/factory_bot.rb +20 -0
  43. data/lib/brut/framework/app.rb +55 -0
  44. data/lib/brut/framework/config.rb +415 -0
  45. data/lib/brut/framework/container.rb +190 -0
  46. data/lib/brut/framework/errors/abstract_method.rb +9 -0
  47. data/lib/brut/framework/errors/bug.rb +14 -0
  48. data/lib/brut/framework/errors/not_found.rb +10 -0
  49. data/lib/brut/framework/errors.rb +14 -0
  50. data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
  51. data/lib/brut/framework/mcp.rb +215 -0
  52. data/lib/brut/framework/project_environment.rb +18 -0
  53. data/lib/brut/framework.rb +13 -0
  54. data/lib/brut/front_end/asset_metadata.rb +76 -0
  55. data/lib/brut/front_end/component.rb +213 -0
  56. data/lib/brut/front_end/components/form_tag.rb +71 -0
  57. data/lib/brut/front_end/components/i18n_translations.rb +36 -0
  58. data/lib/brut/front_end/components/input.rb +13 -0
  59. data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
  60. data/lib/brut/front_end/components/inputs/select.rb +100 -0
  61. data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
  62. data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
  63. data/lib/brut/front_end/components/locale_detection.rb +25 -0
  64. data/lib/brut/front_end/components/page_identifier.rb +13 -0
  65. data/lib/brut/front_end/components/timestamp.rb +33 -0
  66. data/lib/brut/front_end/download.rb +23 -0
  67. data/lib/brut/front_end/flash.rb +57 -0
  68. data/lib/brut/front_end/form.rb +171 -0
  69. data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
  70. data/lib/brut/front_end/forms/input.rb +119 -0
  71. data/lib/brut/front_end/forms/input_definition.rb +100 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +36 -0
  73. data/lib/brut/front_end/handler.rb +48 -0
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
  75. data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
  76. data/lib/brut/front_end/handling_results.rb +14 -0
  77. data/lib/brut/front_end/http_method.rb +33 -0
  78. data/lib/brut/front_end/http_status.rb +16 -0
  79. data/lib/brut/front_end/middleware.rb +7 -0
  80. data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
  81. data/lib/brut/front_end/page.rb +47 -0
  82. data/lib/brut/front_end/request_context.rb +82 -0
  83. data/lib/brut/front_end/route_hook.rb +15 -0
  84. data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
  85. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
  86. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
  87. data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
  88. data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
  89. data/lib/brut/front_end/routing.rb +236 -0
  90. data/lib/brut/front_end/session.rb +56 -0
  91. data/lib/brut/front_end/template.rb +32 -0
  92. data/lib/brut/front_end/templates/block_filter.rb +60 -0
  93. data/lib/brut/front_end/templates/erb_engine.rb +26 -0
  94. data/lib/brut/front_end/templates/erb_parser.rb +84 -0
  95. data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
  96. data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
  97. data/lib/brut/i18n/base_methods.rb +168 -0
  98. data/lib/brut/i18n/for_cli.rb +4 -0
  99. data/lib/brut/i18n/for_html.rb +4 -0
  100. data/lib/brut/i18n/http_accept_language.rb +68 -0
  101. data/lib/brut/i18n.rb +6 -0
  102. data/lib/brut/instrumentation/basic.rb +66 -0
  103. data/lib/brut/instrumentation/event.rb +19 -0
  104. data/lib/brut/instrumentation/http_event.rb +5 -0
  105. data/lib/brut/instrumentation/subscriber.rb +41 -0
  106. data/lib/brut/instrumentation.rb +11 -0
  107. data/lib/brut/junk_drawer.rb +88 -0
  108. data/lib/brut/sinatra_helpers.rb +183 -0
  109. data/lib/brut/spec_support/component_support.rb +49 -0
  110. data/lib/brut/spec_support/flash_support.rb +7 -0
  111. data/lib/brut/spec_support/general_support.rb +18 -0
  112. data/lib/brut/spec_support/handler_support.rb +7 -0
  113. data/lib/brut/spec_support/matcher.rb +9 -0
  114. data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
  115. data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
  116. data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
  117. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
  118. data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
  119. data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
  120. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
  121. data/lib/brut/spec_support/session_support.rb +3 -0
  122. data/lib/brut/spec_support.rb +12 -0
  123. data/lib/brut/version.rb +3 -0
  124. data/lib/brut.rb +38 -0
  125. data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
  126. data/lib/sequel/extensions/brut_migrations.rb +98 -0
  127. data/lib/sequel/plugins/created_at.rb +14 -0
  128. data/lib/sequel/plugins/external_id.rb +45 -0
  129. data/lib/sequel/plugins/find_bang.rb +13 -0
  130. data/lib/sequel/plugins.rb +3 -0
  131. metadata +484 -0
@@ -0,0 +1,100 @@
1
+ class Brut::FrontEnd::Components::Inputs::Select < Brut::FrontEnd::Components::Input
2
+ def self.for_form_input(form:,
3
+ input_name:,
4
+ options:,
5
+ selected_value:,
6
+ value_attribute:,
7
+ option_text_attribute:,
8
+ html_attributes: {})
9
+ default_html_attributes = {}
10
+ input = form[input_name]
11
+ default_html_attributes["required"] = input.required
12
+ if !form.new? && !input.valid?
13
+ default_html_attributes["data-invalid"] = true
14
+ input.validity_state.each do |constraint,violated|
15
+ if violated
16
+ default_html_attributes["data-#{constraint}"] = true
17
+ end
18
+ end
19
+ end
20
+ Brut::FrontEnd::Components::Inputs::Select.new(
21
+ name: input.name,
22
+ options:,
23
+ selected_value:,
24
+ value_attribute:,
25
+ option_text_attribute:,
26
+ html_attributes: default_html_attributes.merge(html_attributes)
27
+ )
28
+ end
29
+ def initialize(name:,
30
+ options:,
31
+ include_blank: false,
32
+ selected_value:,
33
+ value_attribute:,
34
+ option_text_attribute:,
35
+ html_attributes:)
36
+ @options = options
37
+ @include_blank = IncludeBlank.from_param(include_blank)
38
+ @selected_value = selected_value
39
+ @value_attribute = value_attribute
40
+ @option_text_attribute = option_text_attribute
41
+
42
+ html_attributes["name"] = name
43
+ @sanitized_attributes = html_attributes.map { |key,value|
44
+ [
45
+ key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
46
+ value
47
+ ]
48
+ }.select { |key,value|
49
+ !value.nil?
50
+ }.to_h
51
+ end
52
+
53
+ def render
54
+ html_tag(:select,**@sanitized_attributes) {
55
+ options = @options.map { |option|
56
+ value = option.send(@value_attribute)
57
+ option_attributes = { value: value }
58
+ if value == @selected_value
59
+ option_attributes[:selected] = true
60
+ end
61
+ html_tag(:option,**option_attributes) {
62
+ option.send(@option_text_attribute)
63
+ }
64
+ }
65
+ if @include_blank
66
+ options.unshift(html_tag(:option,**@include_blank.option_attributes) {
67
+ @include_blank.text_content
68
+ })
69
+ end
70
+ options.join("\n")
71
+ }
72
+ end
73
+ private
74
+
75
+ class IncludeBlank
76
+ attr_reader :text_content, :option_attributes
77
+ def self.from_param(include_blank)
78
+ if !include_blank
79
+ return nil
80
+ else
81
+ self.new(include_blank)
82
+ end
83
+ end
84
+ def initialize(include_blank)
85
+ if include_blank == true
86
+ @text_content = ""
87
+ @option_attributes = {}
88
+ elsif include_blank.kind_of?(Hash)
89
+ if include_blank.key?(:value) && include_blank.key?(:text_content)
90
+ @text_content = include_blank[:text_content]
91
+ @option_attributes = { value: include_blank[:value] }
92
+ else
93
+ raise ArgumentError, "when include_blank: is a Hash, it must include both :value and :text_content as keys. Got: #{include_blank.keys.join(", ")}"
94
+ end
95
+ else
96
+ raise ArgumentError,"include_blank: was a #{include_blank.class}. It should be true, false, nil, or a Hash"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,63 @@
1
+ class Brut::FrontEnd::Components::Inputs::TextField < Brut::FrontEnd::Components::Input
2
+ def self.for_form_input(form:, input_name:, html_attributes: {})
3
+ default_html_attributes = {}
4
+ input = form[input_name]
5
+ default_html_attributes["required"] = input.required
6
+ default_html_attributes["pattern"] = input.pattern
7
+ default_html_attributes["type"] = input.type
8
+ default_html_attributes["name"] = input.name
9
+ if input.max
10
+ default_html_attributes["max"] = input.max
11
+ end
12
+ if input.maxlength
13
+ default_html_attributes["maxlength"] = input.maxlength
14
+ end
15
+ if input.min
16
+ default_html_attributes["min"] = input.min
17
+ end
18
+ if input.minlength
19
+ default_html_attributes["minlength"] = input.minlength
20
+ end
21
+ if input.step
22
+ default_html_attributes["step"] = input.step
23
+ end
24
+ if input.type == "checkbox"
25
+ default_html_attributes["value"] = "true"
26
+ default_html_attributes["checked"] = input.value == "true"
27
+ else
28
+ default_html_attributes["value"] = input.value
29
+ end
30
+ if !form.new? && !input.valid?
31
+ default_html_attributes["data-invalid"] = true
32
+ input.validity_state.each do |constraint,violated|
33
+ if violated
34
+ default_html_attributes["data-#{constraint}"] = true
35
+ end
36
+ end
37
+ end
38
+ Brut::FrontEnd::Components::Inputs::TextField.new(default_html_attributes.merge(html_attributes))
39
+ end
40
+ def initialize(attributes)
41
+ @sanitized_attributes = attributes.map { |key,value|
42
+ [
43
+ key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
44
+ value
45
+ ]
46
+ }.select { |key,value|
47
+ !value.nil?
48
+ }.to_h
49
+ end
50
+
51
+ def render
52
+ attribute_string = @sanitized_attributes.map { |key,value|
53
+ if value == true
54
+ key
55
+ elsif value == false
56
+ ""
57
+ else
58
+ REXML::Attribute.new(key,value).to_string
59
+ end
60
+ }.join(" ")
61
+ "<input #{attribute_string}>"
62
+ end
63
+ end
@@ -0,0 +1,51 @@
1
+ class Brut::FrontEnd::Components::Inputs::Textarea < Brut::FrontEnd::Components::Input
2
+ def self.for_form_input(form:, input_name:, html_attributes: {})
3
+ default_html_attributes = {}
4
+ input = form[input_name]
5
+ default_html_attributes["required"] = input.required
6
+ default_html_attributes["name"] = input.name
7
+ if input.maxlength
8
+ default_html_attributes["maxlength"] = input.maxlength
9
+ end
10
+ if input.minlength
11
+ default_html_attributes["minlength"] = input.minlength
12
+ end
13
+ if !form.new? && !input.valid?
14
+ default_html_attributes["data-invalid"] = true
15
+ input.validity_state.each do |constraint,violated|
16
+ if violated
17
+ default_html_attributes["data-#{constraint}"] = true
18
+ end
19
+ end
20
+ end
21
+ Brut::FrontEnd::Components::Inputs::Textarea.new(default_html_attributes.merge(html_attributes), input.value)
22
+ end
23
+ def initialize(attributes, value)
24
+ @sanitized_attributes = attributes.map { |key,value|
25
+ [
26
+ key.to_s.gsub(/[\s\"\'>\/=]/,"-"),
27
+ value
28
+ ]
29
+ }.select { |key,value|
30
+ !value.nil?
31
+ }.to_h
32
+ @value = value
33
+ end
34
+
35
+ def sanitized_attributes = @sanitized_attributes
36
+
37
+ def render
38
+ attribute_string = @sanitized_attributes.map { |key,value|
39
+ if value == true
40
+ key
41
+ elsif value == false
42
+ ""
43
+ else
44
+ REXML::Attribute.new(key,value).to_string
45
+ end
46
+ }.join(" ")
47
+ %{
48
+ <textarea #{attribute_string}>#{ @value }</textarea>
49
+ }
50
+ end
51
+ end
@@ -0,0 +1,25 @@
1
+ # Produces `<brut-locale-detection>`
2
+ class Brut::FrontEnd::Components::LocaleDetection < Brut::FrontEnd::Component
3
+ def initialize(session:)
4
+ @timezone = session.timezone_from_browser
5
+ @locale = session.http_accept_language.known? ? session.http_accept_language.weighted_locales.first&.locale : nil
6
+ @url = Brut::FrontEnd::Handlers::LocaleDetectionHandler.routing
7
+ end
8
+
9
+ def render
10
+ attributes = {
11
+ "url" => @url,
12
+ }
13
+ if @timezone
14
+ attributes["timezone-from-server"] = @timezone.name
15
+ end
16
+ if @locale
17
+ attributes["locale-from-server"] = @locale
18
+ end
19
+ if !Brut.container.project_env.production?
20
+ attributes["show-warnings"] = true
21
+ end
22
+
23
+ html_tag("brut-locale-detection",**attributes)
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ require "rexml"
2
+ class Brut::FrontEnd::Components::PageIdentifier < Brut::FrontEnd::Component
3
+ def initialize(page_name)
4
+ @page_name = page_name
5
+ end
6
+
7
+ def render
8
+ if Brut.container.project_env.production?
9
+ return ""
10
+ end
11
+ html_tag(:meta, name: "class", content: @page_name)
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ require "rexml"
2
+ class Brut::FrontEnd::Components::Timestamp < Brut::FrontEnd::Component
3
+ include Brut::I18n::ForHTML
4
+ def initialize(timestamp:, format: :full, skip_year_if_same: true, attribute_format: :iso_8601, **only_contains_class)
5
+ @timestamp = timestamp
6
+ formats = [ format ]
7
+ if @timestamp.year == Time.now.year && skip_year_if_same
8
+ formats.unshift("#{format}_no_year")
9
+ end
10
+ format_keys = formats.map { |f| "time.formats.#{f}" }
11
+ found_format = formats.zip(::I18n.t(format_keys)).detect { |(key,value)|
12
+ value !~ /^Translation missing/
13
+ }.first
14
+ if found_format.nil?
15
+ raise ArgumentError,"format #{format} is not a known time format"
16
+ end
17
+ @format = found_format.to_sym
18
+
19
+ if ::I18n.t("time.formats.#{attribute_format}") =~ /^Translation missing/
20
+ raise ArgumentError,"attribute_format #{attribute_format} is not a known time format"
21
+ end
22
+ @attribute_format = attribute_format.to_sym
23
+ @class_attribute = only_contains_class[:class] || ""
24
+ end
25
+
26
+
27
+ def render(clock:)
28
+ timestamp_in_time_zone = clock.in_time_zone(@timestamp)
29
+ html_tag(:time, class: @class_attribute, datetime: ::I18n.l(timestamp_in_time_zone,format: @attribute_format)) do
30
+ ::I18n.l(timestamp_in_time_zone,format: @format)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,23 @@
1
+ class Brut::FrontEnd::Download
2
+
3
+ attr_reader :data
4
+
5
+ def initialize(filename:,data:,content_type:,timestamp: false)
6
+ @filename = filename
7
+ @data = data
8
+ @content_type = content_type
9
+ @timestamp = timestamp
10
+ end
11
+
12
+ def headers
13
+ filename = if @timestamp
14
+ Time.now.strftime("%Y-%m-%dT%H-%M-%S") + "-" + @filename
15
+ else
16
+ @filename
17
+ end
18
+ {
19
+ "content-disposition" => "attachment; filename=\"#{filename}\"",
20
+ "content-type" => @content_type,
21
+ }
22
+ end
23
+ end
@@ -0,0 +1,57 @@
1
+ class Brut::FrontEnd::Flash
2
+ def self.from_h(hash)
3
+ hash ||= {}
4
+ self.new(
5
+ age: hash[:age] || 0,
6
+ messages: hash[:messages] || {}
7
+ )
8
+ end
9
+ def initialize(age: 0, messages: {})
10
+ @age = age.to_i
11
+ if !messages.kind_of?(Hash)
12
+ raise ArgumentError,"messages must be a Hash, not a #{messages.class}"
13
+ end
14
+ @messages = messages
15
+ end
16
+
17
+ def clear!
18
+ @age = 0
19
+ @messages = {}
20
+ end
21
+
22
+ def notice=(notice)
23
+ self[:notice] = notice
24
+ end
25
+ def notice = self[:notice]
26
+ def notice? = !!self.notice
27
+
28
+ def alert=(alert)
29
+ self[:alert] = alert
30
+ end
31
+ def alert = self[:alert]
32
+ def alert? = !!self.alert
33
+
34
+ def age!
35
+ @age += 1
36
+ if @age > 1
37
+ @age = 0
38
+ @messages = {}
39
+ end
40
+ end
41
+
42
+ def [](key)
43
+ @messages[key]
44
+ end
45
+
46
+ def []=(key,message)
47
+ @messages[key] = message
48
+ @age = [0,@age-1].max
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ age: @age,
54
+ messages: @messages,
55
+ }
56
+ end
57
+ end
@@ -0,0 +1,171 @@
1
+ require "forwardable"
2
+
3
+ module Brut::FrontEnd::Forms
4
+ autoload(:InputDefinition, "brut/front_end/forms/input_definition")
5
+ autoload(:ConstraintViolation, "brut/front_end/forms/constraint_violation")
6
+ autoload(:ValidityState, "brut/front_end/forms/validity_state")
7
+ autoload(:Input, "brut/front_end/forms/input")
8
+ end
9
+
10
+ module Brut::FrontEnd::FormInputDeclaration
11
+ # Declares an input for this form.
12
+ def input(name,attributes={})
13
+ self.add_input_definition(
14
+ Brut::FrontEnd::Forms::InputDefinition.new(**(attributes.merge(name: name)))
15
+ )
16
+ end
17
+
18
+ def select(name,attributes={})
19
+ self.add_input_definition(
20
+ Brut::FrontEnd::Forms::SelectInputDefinition.new(**(attributes.merge(name: name)))
21
+ )
22
+ end
23
+
24
+ def add_input_definition(input_definition)
25
+ @input_definitions ||= {}
26
+ @input_definitions[input_definition.name] = input_definition
27
+ define_method input_definition.name do
28
+ self[input_definition.name].value
29
+ end
30
+ end
31
+
32
+ # Copy the inputs from another form into this one
33
+ def inputs_from(other_class)
34
+ if !other_class.respond_to?(:input_definitions)
35
+ raise ArgumentError,"#{other_class} does not respond to #input_definitions - you cannot copy inputs from it"
36
+ end
37
+ other_class.input_definitions.each do |_name,input_definition|
38
+ self.add_input_definition(input_definition)
39
+ end
40
+ end
41
+
42
+ def input_definitions = @input_definitions || {}
43
+ end
44
+
45
+ class Brut::FrontEnd::Form
46
+
47
+ include SemanticLogger::Loggable
48
+
49
+ extend Brut::FrontEnd::FormInputDeclaration
50
+
51
+ def self.routing(*)
52
+ raise ArgumentError,"You called .routing on a form, but that form hasn't been configured with a route. You must do so in your route_config.rb file via the `form` method"
53
+ end
54
+
55
+ # Create an instance of this form, optionally initialized with
56
+ # the given values for its params.
57
+ def initialize(params: {})
58
+ params = convert_to_string_or_nil(params.to_h)
59
+ unknown_params = params.keys.map(&:to_s).reject { |key|
60
+ self.class.input_definitions.key?(key)
61
+ }
62
+ if unknown_params.any?
63
+ logger.info "Ignoring unknown params", keys: unknown_params
64
+ end
65
+ @params = params.except(*unknown_params)
66
+ @new = params_empty?(@params)
67
+ @inputs = self.class.input_definitions.map { |name,input_definition|
68
+ input = input_definition.make_input(value: @params[name] || @params[name.to_sym])
69
+ [ name, input ]
70
+ }.to_h
71
+ end
72
+
73
+ # Returns true if this form represents a new, empty, untouched form. This is
74
+ # useful for determining if this form has never been submitted and thus
75
+ # any required values don't represent an intentional omission by the user.
76
+ def new? = @new
77
+
78
+ # Access an input with the given name
79
+ def [](input_name) = @inputs.fetch(input_name.to_s)
80
+
81
+ # Returns true if this form has constraint violations.
82
+ def constraint_violations? = !@inputs.values.all?(&:valid?)
83
+
84
+ # Set a server-side constraint violation on a given input's name.
85
+ def server_side_constraint_violation(input_name:, key:, context:{})
86
+ self[input_name].server_side_constraint_violation(key,context)
87
+ end
88
+
89
+ def constraint_violations(server_side_only: false)
90
+ @inputs.map { |input_name, input|
91
+ if input.valid?
92
+ nil
93
+ else
94
+ [
95
+ input_name,
96
+ input.validity_state.select { |constraint|
97
+ if server_side_only
98
+ !constraint.client_side?
99
+ else
100
+ true
101
+ end
102
+ }
103
+ ]
104
+ end
105
+ }.compact.to_h
106
+ end
107
+
108
+ private
109
+
110
+ def params_empty?(params) = params.nil? || params.empty?
111
+
112
+ def convert_to_string_or_nil(hash)
113
+ hash.each do |key,value|
114
+ case value
115
+ in Hash then convert_to_string_or_nil(value)
116
+ in String then hash[key] = RichString.new(value).to_s_or_nil
117
+ in Numeric then hash[key] = value.to_s
118
+ in TrueClass then hash[key] = "true"
119
+ in FalseClass then hash[key] = "false"
120
+ in NilClass then # it's fine
121
+ else
122
+ if Brut.container.project_env.test?
123
+ raise ArgumentError, "Got #{value.class} for #{key} in params hash, which is not expected"
124
+ else
125
+ logger.warn("Got #{value.class} for #{key} in params hash, which is not expected")
126
+ end
127
+ end
128
+ end
129
+ end
130
+
131
+ end
132
+
133
+ class Brut::FrontEnd::FormProcessingResponse
134
+
135
+ def self.redirect_to(uri) = Redirect.new(uri)
136
+ def self.render_page(page) = RenderPage.new(page)
137
+ def self.render_component(component, http_status: 200) = RenderComponent.new(component,http_status)
138
+ def self.send_http_status(http_status) = SendHttpStatusOnly.new(http_status)
139
+
140
+ class Redirect < Brut::FrontEnd::FormProcessingResponse
141
+ def initialize(uri)
142
+ @uri = uri
143
+ end
144
+
145
+ def deconstruct_keys(keys) = { redirect: @uri }
146
+
147
+ end
148
+
149
+ class RenderPage < Brut::FrontEnd::FormProcessingResponse
150
+ def initialize(page)
151
+ @page = page
152
+ end
153
+ def deconstruct_keys(keys) = { page_instance: @page }
154
+ end
155
+
156
+ class RenderComponent < Brut::FrontEnd::FormProcessingResponse
157
+ def initialize(component, http_status)
158
+ @component = component
159
+ @http_status = http_status
160
+ end
161
+ def deconstruct_keys(keys) = { component_instance: @component, http_status: @http_status }
162
+ end
163
+
164
+ class SendHttpStatusOnly < Brut::FrontEnd::FormProcessingResponse
165
+ def initialize(http_status)
166
+ @http_status = http_status
167
+ end
168
+ def deconstruct_keys(keys) = { http_status: @http_status }
169
+ end
170
+
171
+ end
@@ -0,0 +1,39 @@
1
+ # Represents a specific error with a field. A field can have any number of constraint violations
2
+ # to indicate what is wrong with it.
3
+ class Brut::FrontEnd::Forms::ConstraintViolation
4
+
5
+ CLIENT_SIDE_KEYS = [
6
+ "bad_input",
7
+ "custom_error",
8
+ "pattern_mismatch",
9
+ "range_overflow",
10
+ "range_underflow",
11
+ "step_mismatch",
12
+ "too_long",
13
+ "too_short",
14
+ "type_mismatch",
15
+ "value_missing",
16
+ ]
17
+
18
+ attr_reader :key, :context
19
+
20
+ def initialize(key:,context:, server_side: :based_on_key)
21
+ @key = key.to_s
22
+ @client_side = CLIENT_SIDE_KEYS.include?(@key) && server_side != true
23
+ @context = context || {}
24
+ if !@context.kind_of?(Hash)
25
+ raise "#{self.class} created for key #{key} with an invalid context: '#{context}/#{context.class}'. Context must be nil or a hash"
26
+ end
27
+ end
28
+
29
+ def client_side? = @client_side
30
+ def to_s = @key
31
+
32
+ def to_json(*args)
33
+ {
34
+ key: self.key,
35
+ context: self.context,
36
+ client_side: self.client_side?
37
+ }.to_json(*args)
38
+ end
39
+ end
@@ -0,0 +1,119 @@
1
+ # An Input is a stateful object representing a specific input and its value
2
+ # during the course of a form submission process. In particular, it wraps a value
3
+ # and a ValidityState. These are mutable, whereas the wrapped InputDefinition is not.
4
+ class Brut::FrontEnd::Forms::Input
5
+
6
+ extend Forwardable
7
+
8
+ attr_reader :value, :validity_state
9
+
10
+ def initialize(input_definition:, value:)
11
+ @input_definition = input_definition
12
+ @validity_state = Brut::FrontEnd::Forms::ValidityState.new
13
+ self.value=(value)
14
+ end
15
+
16
+ def_delegators :"@input_definition", :max,
17
+ :maxlength,
18
+ :min,
19
+ :minlength,
20
+ :name,
21
+ :pattern,
22
+ :required,
23
+ :step,
24
+ :type
25
+
26
+ def value=(new_value)
27
+ value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
28
+ missing = if self.required
29
+ value_missing
30
+ else
31
+ false
32
+ end
33
+ too_short = if self.minlength && !value_missing
34
+ new_value.length < self.minlength
35
+ else
36
+ false
37
+ end
38
+
39
+ too_long = if self.maxlength && !value_missing
40
+ new_value.length > self.maxlength
41
+ else
42
+ false
43
+ end
44
+
45
+ type_mismatch = false # TBD
46
+
47
+ range_overflow = if self.max && !value_missing && !type_mismatch
48
+ new_value.to_i > self.max
49
+ else
50
+ false
51
+ end
52
+
53
+ range_underflow = if self.min && !value_missing && !type_mismatch
54
+ new_value.to_i < self.min
55
+ else
56
+ false
57
+ end
58
+
59
+ pattern_mismatch = false
60
+ step_mismatch = false
61
+
62
+ @validity_state = Brut::FrontEnd::Forms::ValidityState.new(
63
+ value_missing: missing,
64
+ too_short: too_short,
65
+ too_long: too_short,
66
+ range_overflow: range_overflow,
67
+ range_underflow: range_underflow,
68
+ pattern_mismatch: pattern_mismatch,
69
+ step_mismatch: step_mismatch,
70
+ type_mismatch: type_mismatch,
71
+ )
72
+ @value = new_value
73
+ end
74
+
75
+ # Set a server-side constraint violation on this input. This is essentially arbitrary, but note
76
+ # that `key` should not be a key used for client-side validations.
77
+ def server_side_constraint_violation(key,context=true)
78
+ @validity_state.server_side_constraint_violation(key: key, context: context)
79
+ end
80
+
81
+ def valid? = @validity_state.valid?
82
+ end
83
+ class Brut::FrontEnd::Forms::SelectInput
84
+
85
+ extend Forwardable
86
+
87
+ attr_reader :value, :validity_state
88
+
89
+ def initialize(input_definition:, value:)
90
+ @input_definition = input_definition
91
+ @validity_state = Brut::FrontEnd::Forms::ValidityState.new
92
+ self.value=(value)
93
+ end
94
+
95
+ def_delegators :"@input_definition", :name,
96
+ :required
97
+
98
+ def value=(new_value)
99
+ value_missing = new_value.nil? || (new_value.kind_of?(String) && new_value.strip == "")
100
+ missing = if self.required
101
+ value_missing
102
+ else
103
+ false
104
+ end
105
+
106
+ @validity_state = Brut::FrontEnd::Forms::ValidityState.new(
107
+ value_missing: missing,
108
+ )
109
+ @value = new_value
110
+ end
111
+
112
+ # Set a server-side constraint violation on this input. This is essentially arbitrary, but note
113
+ # that `key` should not be a key used for client-side validations.
114
+ def server_side_constraint_violation(key,context=true)
115
+ @validity_state.server_side_constraint_violation(key: key, context: context)
116
+ end
117
+
118
+ def valid? = @validity_state.valid?
119
+ end