brut 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (131) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +7 -0
  3. data/CODE_OF_CONDUCT.txt +99 -0
  4. data/Dockerfile.dx +32 -0
  5. data/Gemfile +6 -0
  6. data/Gemfile.lock +133 -0
  7. data/LICENSE.txt +370 -0
  8. data/README.md +21 -0
  9. data/Rakefile +1 -0
  10. data/bin/bin_kit.rb +39 -0
  11. data/bin/rake +27 -0
  12. data/bin/setup +145 -0
  13. data/brut.gemspec +60 -0
  14. data/docker-compose.dx.yml +16 -0
  15. data/dx/build +26 -0
  16. data/dx/docker-compose.env +22 -0
  17. data/dx/dx.sh.lib +24 -0
  18. data/dx/exec +58 -0
  19. data/dx/prune +19 -0
  20. data/dx/setupkit.sh.lib +144 -0
  21. data/dx/show-help-in-app-container-then-wait.sh +38 -0
  22. data/dx/start +30 -0
  23. data/dx/stop +23 -0
  24. data/lib/brut/back_end/action.rb +3 -0
  25. data/lib/brut/back_end/result.rb +46 -0
  26. data/lib/brut/back_end/seed_data.rb +24 -0
  27. data/lib/brut/back_end/validator.rb +3 -0
  28. data/lib/brut/back_end/validators/form_validator.rb +37 -0
  29. data/lib/brut/cli/app.rb +130 -0
  30. data/lib/brut/cli/app_runner.rb +219 -0
  31. data/lib/brut/cli/apps/build_assets.rb +123 -0
  32. data/lib/brut/cli/apps/db.rb +279 -0
  33. data/lib/brut/cli/apps/scaffold.rb +256 -0
  34. data/lib/brut/cli/apps/test.rb +200 -0
  35. data/lib/brut/cli/command.rb +130 -0
  36. data/lib/brut/cli/error.rb +12 -0
  37. data/lib/brut/cli/execution_results.rb +81 -0
  38. data/lib/brut/cli/executor.rb +37 -0
  39. data/lib/brut/cli/options.rb +46 -0
  40. data/lib/brut/cli/output.rb +30 -0
  41. data/lib/brut/cli.rb +24 -0
  42. data/lib/brut/factory_bot.rb +20 -0
  43. data/lib/brut/framework/app.rb +55 -0
  44. data/lib/brut/framework/config.rb +415 -0
  45. data/lib/brut/framework/container.rb +190 -0
  46. data/lib/brut/framework/errors/abstract_method.rb +9 -0
  47. data/lib/brut/framework/errors/bug.rb +14 -0
  48. data/lib/brut/framework/errors/not_found.rb +10 -0
  49. data/lib/brut/framework/errors.rb +14 -0
  50. data/lib/brut/framework/fussy_type_enforcement.rb +50 -0
  51. data/lib/brut/framework/mcp.rb +215 -0
  52. data/lib/brut/framework/project_environment.rb +18 -0
  53. data/lib/brut/framework.rb +13 -0
  54. data/lib/brut/front_end/asset_metadata.rb +76 -0
  55. data/lib/brut/front_end/component.rb +213 -0
  56. data/lib/brut/front_end/components/form_tag.rb +71 -0
  57. data/lib/brut/front_end/components/i18n_translations.rb +36 -0
  58. data/lib/brut/front_end/components/input.rb +13 -0
  59. data/lib/brut/front_end/components/inputs/csrf_token.rb +8 -0
  60. data/lib/brut/front_end/components/inputs/select.rb +100 -0
  61. data/lib/brut/front_end/components/inputs/text_field.rb +63 -0
  62. data/lib/brut/front_end/components/inputs/textarea.rb +51 -0
  63. data/lib/brut/front_end/components/locale_detection.rb +25 -0
  64. data/lib/brut/front_end/components/page_identifier.rb +13 -0
  65. data/lib/brut/front_end/components/timestamp.rb +33 -0
  66. data/lib/brut/front_end/download.rb +23 -0
  67. data/lib/brut/front_end/flash.rb +57 -0
  68. data/lib/brut/front_end/form.rb +171 -0
  69. data/lib/brut/front_end/forms/constraint_violation.rb +39 -0
  70. data/lib/brut/front_end/forms/input.rb +119 -0
  71. data/lib/brut/front_end/forms/input_definition.rb +100 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +36 -0
  73. data/lib/brut/front_end/handler.rb +48 -0
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +11 -0
  75. data/lib/brut/front_end/handlers/locale_detection_handler.rb +22 -0
  76. data/lib/brut/front_end/handling_results.rb +14 -0
  77. data/lib/brut/front_end/http_method.rb +33 -0
  78. data/lib/brut/front_end/http_status.rb +16 -0
  79. data/lib/brut/front_end/middleware.rb +7 -0
  80. data/lib/brut/front_end/middlewares/reload_app.rb +31 -0
  81. data/lib/brut/front_end/page.rb +47 -0
  82. data/lib/brut/front_end/request_context.rb +82 -0
  83. data/lib/brut/front_end/route_hook.rb +15 -0
  84. data/lib/brut/front_end/route_hooks/age_flash.rb +8 -0
  85. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +17 -0
  86. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +46 -0
  87. data/lib/brut/front_end/route_hooks/locale_detection.rb +24 -0
  88. data/lib/brut/front_end/route_hooks/setup_request_context.rb +11 -0
  89. data/lib/brut/front_end/routing.rb +236 -0
  90. data/lib/brut/front_end/session.rb +56 -0
  91. data/lib/brut/front_end/template.rb +32 -0
  92. data/lib/brut/front_end/templates/block_filter.rb +60 -0
  93. data/lib/brut/front_end/templates/erb_engine.rb +26 -0
  94. data/lib/brut/front_end/templates/erb_parser.rb +84 -0
  95. data/lib/brut/front_end/templates/escapable_filter.rb +18 -0
  96. data/lib/brut/front_end/templates/html_safe_string.rb +40 -0
  97. data/lib/brut/i18n/base_methods.rb +168 -0
  98. data/lib/brut/i18n/for_cli.rb +4 -0
  99. data/lib/brut/i18n/for_html.rb +4 -0
  100. data/lib/brut/i18n/http_accept_language.rb +68 -0
  101. data/lib/brut/i18n.rb +6 -0
  102. data/lib/brut/instrumentation/basic.rb +66 -0
  103. data/lib/brut/instrumentation/event.rb +19 -0
  104. data/lib/brut/instrumentation/http_event.rb +5 -0
  105. data/lib/brut/instrumentation/subscriber.rb +41 -0
  106. data/lib/brut/instrumentation.rb +11 -0
  107. data/lib/brut/junk_drawer.rb +88 -0
  108. data/lib/brut/sinatra_helpers.rb +183 -0
  109. data/lib/brut/spec_support/component_support.rb +49 -0
  110. data/lib/brut/spec_support/flash_support.rb +7 -0
  111. data/lib/brut/spec_support/general_support.rb +18 -0
  112. data/lib/brut/spec_support/handler_support.rb +7 -0
  113. data/lib/brut/spec_support/matcher.rb +9 -0
  114. data/lib/brut/spec_support/matchers/be_a_bug.rb +14 -0
  115. data/lib/brut/spec_support/matchers/be_page_for.rb +14 -0
  116. data/lib/brut/spec_support/matchers/be_routing_for.rb +11 -0
  117. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +56 -0
  118. data/lib/brut/spec_support/matchers/have_html_attribute.rb +69 -0
  119. data/lib/brut/spec_support/matchers/have_rendered.rb +20 -0
  120. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +27 -0
  121. data/lib/brut/spec_support/session_support.rb +3 -0
  122. data/lib/brut/spec_support.rb +12 -0
  123. data/lib/brut/version.rb +3 -0
  124. data/lib/brut.rb +38 -0
  125. data/lib/sequel/extensions/brut_instrumentation.rb +37 -0
  126. data/lib/sequel/extensions/brut_migrations.rb +98 -0
  127. data/lib/sequel/plugins/created_at.rb +14 -0
  128. data/lib/sequel/plugins/external_id.rb +45 -0
  129. data/lib/sequel/plugins/find_bang.rb +13 -0
  130. data/lib/sequel/plugins.rb +3 -0
  131. metadata +484 -0
@@ -0,0 +1,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