brut 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +6 -0
  3. data/Gemfile.lock +66 -1
  4. data/README.md +36 -0
  5. data/Rakefile +22 -0
  6. data/brut.gemspec +7 -0
  7. data/doc-src/architecture.md +102 -0
  8. data/doc-src/assets.md +98 -0
  9. data/doc-src/forms.md +214 -0
  10. data/doc-src/handlers.md +83 -0
  11. data/doc-src/javascript.md +265 -0
  12. data/doc-src/keyword-injection.md +183 -0
  13. data/doc-src/pages.md +210 -0
  14. data/doc-src/route-hooks.md +59 -0
  15. data/docs-todo.md +32 -0
  16. data/lib/brut/back_end/seed_data.rb +5 -1
  17. data/lib/brut/back_end/validator.rb +1 -1
  18. data/lib/brut/back_end/validators/form_validator.rb +31 -6
  19. data/lib/brut/cli/app.rb +100 -4
  20. data/lib/brut/cli/app_runner.rb +38 -5
  21. data/lib/brut/cli/apps/build_assets.rb +4 -6
  22. data/lib/brut/cli/apps/db.rb +2 -7
  23. data/lib/brut/cli/apps/scaffold.rb +413 -7
  24. data/lib/brut/cli/apps/test.rb +14 -1
  25. data/lib/brut/cli/command.rb +141 -6
  26. data/lib/brut/cli/error.rb +30 -3
  27. data/lib/brut/cli/execution_results.rb +44 -6
  28. data/lib/brut/cli/executor.rb +21 -1
  29. data/lib/brut/cli/options.rb +29 -2
  30. data/lib/brut/cli/output.rb +47 -0
  31. data/lib/brut/cli.rb +26 -0
  32. data/lib/brut/factory_bot.rb +2 -0
  33. data/lib/brut/framework/app.rb +38 -5
  34. data/lib/brut/framework/config.rb +97 -54
  35. data/lib/brut/framework/container.rb +97 -33
  36. data/lib/brut/framework/errors/abstract_method.rb +3 -7
  37. data/lib/brut/framework/errors/missing_parameter.rb +12 -0
  38. data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
  39. data/lib/brut/framework/errors/not_found.rb +12 -2
  40. data/lib/brut/framework/errors/not_implemented.rb +14 -0
  41. data/lib/brut/framework/errors.rb +18 -0
  42. data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
  43. data/lib/brut/framework/mcp.rb +106 -49
  44. data/lib/brut/framework/project_environment.rb +9 -0
  45. data/lib/brut/framework.rb +1 -0
  46. data/lib/brut/front_end/asset_metadata.rb +7 -1
  47. data/lib/brut/front_end/component.rb +129 -38
  48. data/lib/brut/front_end/components/constraint_violations.rb +57 -0
  49. data/lib/brut/front_end/components/form_tag.rb +23 -32
  50. data/lib/brut/front_end/components/i18n_translations.rb +34 -1
  51. data/lib/brut/front_end/components/input.rb +3 -0
  52. data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
  53. data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
  54. data/lib/brut/front_end/components/inputs/select.rb +26 -2
  55. data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
  56. data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
  57. data/lib/brut/front_end/components/locale_detection.rb +8 -1
  58. data/lib/brut/front_end/components/page_identifier.rb +2 -0
  59. data/lib/brut/front_end/components/time.rb +95 -0
  60. data/lib/brut/front_end/components/traceparent.rb +22 -0
  61. data/lib/brut/front_end/download.rb +11 -0
  62. data/lib/brut/front_end/flash.rb +32 -0
  63. data/lib/brut/front_end/form.rb +109 -106
  64. data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
  65. data/lib/brut/front_end/forms/input.rb +30 -42
  66. data/lib/brut/front_end/forms/input_declarations.rb +90 -0
  67. data/lib/brut/front_end/forms/input_definition.rb +45 -30
  68. data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
  69. data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
  70. data/lib/brut/front_end/forms/select_input.rb +47 -0
  71. data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
  72. data/lib/brut/front_end/forms/validity_state.rb +23 -9
  73. data/lib/brut/front_end/handler.rb +27 -8
  74. data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
  75. data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
  76. data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
  77. data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
  78. data/lib/brut/front_end/handling_results.rb +14 -4
  79. data/lib/brut/front_end/http_method.rb +13 -1
  80. data/lib/brut/front_end/http_status.rb +10 -0
  81. data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
  82. data/lib/brut/front_end/middleware.rb +5 -0
  83. data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
  84. data/lib/brut/front_end/middlewares/favicon.rb +16 -0
  85. data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
  86. data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
  87. data/lib/brut/front_end/page.rb +50 -11
  88. data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
  89. data/lib/brut/front_end/pages/missing_page.rb +36 -0
  90. data/lib/brut/front_end/request_context.rb +117 -8
  91. data/lib/brut/front_end/route_hook.rb +43 -1
  92. data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
  93. data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
  94. data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
  95. data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
  96. data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
  97. data/lib/brut/front_end/routing.rb +138 -31
  98. data/lib/brut/front_end/session.rb +86 -7
  99. data/lib/brut/front_end/template.rb +17 -2
  100. data/lib/brut/front_end/templates/block_filter.rb +4 -3
  101. data/lib/brut/front_end/templates/erb_parser.rb +1 -1
  102. data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
  103. data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
  104. data/lib/brut/front_end/templates/locator.rb +60 -0
  105. data/lib/brut/i18n/base_methods.rb +4 -0
  106. data/lib/brut/i18n.rb +1 -0
  107. data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
  108. data/lib/brut/instrumentation/open_telemetry.rb +107 -0
  109. data/lib/brut/instrumentation.rb +4 -6
  110. data/lib/brut/junk_drawer.rb +54 -4
  111. data/lib/brut/sinatra_helpers.rb +42 -38
  112. data/lib/brut/spec_support/clock_support.rb +6 -0
  113. data/lib/brut/spec_support/component_support.rb +53 -26
  114. data/lib/brut/spec_support/e2e_test_server.rb +82 -0
  115. data/lib/brut/spec_support/enhanced_node.rb +45 -0
  116. data/lib/brut/spec_support/general_support.rb +14 -3
  117. data/lib/brut/spec_support/handler_support.rb +2 -0
  118. data/lib/brut/spec_support/matcher.rb +6 -3
  119. data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
  120. data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
  121. data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
  122. data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
  123. data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
  124. data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
  125. data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
  126. data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
  127. data/lib/brut/spec_support/rspec_setup.rb +182 -0
  128. data/lib/brut/spec_support.rb +8 -3
  129. data/lib/brut/version.rb +2 -1
  130. data/lib/brut.rb +28 -5
  131. data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
  132. data/lib/sequel/extensions/brut_migrations.rb +18 -8
  133. data/lib/sequel/plugins/created_at.rb +2 -0
  134. data/lib/sequel/plugins/external_id.rb +39 -1
  135. data/lib/sequel/plugins/find_bang.rb +4 -1
  136. metadata +140 -13
  137. data/lib/brut/back_end/action.rb +0 -3
  138. data/lib/brut/back_end/result.rb +0 -46
  139. data/lib/brut/front_end/components/timestamp.rb +0 -33
  140. data/lib/brut/instrumentation/basic.rb +0 -66
  141. data/lib/brut/instrumentation/event.rb +0 -19
  142. data/lib/brut/instrumentation/http_event.rb +0 -5
  143. data/lib/brut/instrumentation/subscriber.rb +0 -41
data/doc-src/pages.md ADDED
@@ -0,0 +1,210 @@
1
+ # Pages and Components
2
+
3
+ A website is made up of pages. Even a web *app* has pages. Thus, the most basic way to create dynamic behavior with Brut is the
4
+ *page*. In Brut, a page is a URL, a subclass of {Brut::FrontEnd::Page}, and an ERB template. The template is rendered in the context
5
+ of an instance of the class whenever the URL is requested.
6
+
7
+ ## Overview of Rendering Pages
8
+
9
+ In your `app.rb`, you first declare a page using the {Brut::SinatraHelpers::ClassMethods#page} method (inside the block given to {Brut::Framework::App.routes}):
10
+
11
+ class App < Brut::Framework::App
12
+
13
+ routes do
14
+
15
+ page "/sign_in"
16
+ end
17
+ end
18
+
19
+ The name of the route must start with a `/` and the rest of the route determines the name of the page. In the example above, Brut
20
+ will expect to find a class named `SignInPage`, which should be located in `app/src/front_end/pages`. You can create it with
21
+ `bin/scaffold`:
22
+
23
+ > bin/scaffold page SignInPage
24
+
25
+ The page class must subclass {Brut::FrontEnd::Page}. You write a constructor to accept whatever your page needs to assemble the data
26
+ needed to render it.
27
+
28
+ The HTML is generated based on an ERB file located in `app/src/front_end/pages`. In this example, that's
29
+ `app/src/front_end/pages/sign_in_page.html.erb`. That ERB is executed with an instance of the page class as context. That means you
30
+ can references any methods or ivars.
31
+
32
+ ## Creating a Page Class
33
+
34
+ {Brut::FrontEnd::Page#render} is called by Brut and expects to receive HTML, which it will send as the body of the response. `render`
35
+ manages the ERB rendering, so the bulk of your page's logic will be in other methods. The constructor is where any data you need is
36
+ provided.
37
+
38
+ A page's `initialize` method must accept only keyword arguments and those keywords are used by Brut to determine what data needs to be
39
+ passed in. This technique, called *{file:doc-src/keyword-injection.md Keyword Injection}*, is used in several other places in Brut.
40
+
41
+ When the page's URL is requested, an instance of your page class is created, passing in the values needed. Beyond that, however, your page class is a normal Ruby class. You can save data to instance variables, implement attributes or other methods. Basically, whatever logic your page needs to render will exist in the page class.
42
+
43
+ ## Implementing Your ERB
44
+
45
+ ERB is part of the Ruby standard library that allows the creation of dynamic templates. Brut's ERB is close to this implementation, but has a few additional features that make working with HTML a bit safer.
46
+
47
+ ### Your Page is the Only Context
48
+
49
+ The instance of your page class is the only context available to the ERB. There is no set of global helpers that are injected, nor
50
+ are there any modules added when the page is rendered. Anything you need to do in your ERB must be available to the page class you've
51
+ created.
52
+
53
+ The reason for this is to keep things simple. If your page's HTML needs access to a lot of stuff, your page class will be the source
54
+ of truth as to where that stuff comes from. You will always be able to figure out where methods are defined by looking at the page
55
+ class or any of its ancestors. You are free to `include` as many modules as you like, or dump lots of methods into your `AppPage`
56
+ base class. But you don't have to.
57
+
58
+ ### All Strings are HTML-Escaped
59
+
60
+ Any instance of a `String` is HTML-escaped by Brut before being inserted into the HTML being generated by the ERB. This is, generally, what you want to have happen. If you have a string that you believe is safe and does not need to be escaped, you can prevent HTML-escaping in one of two ways:
61
+
62
+ * Call {Brut::FrontEnd::Component#html_safe!} on the string
63
+
64
+ <%= html_safe!(get_value) %>
65
+
66
+ * Wrap the string in a {Brut::FrontEnd::Templates::HTMLSafeString}.
67
+
68
+ def get_value
69
+ Brut::FrontEnd::Templates::HTMLSafeString.from_string(some_value)
70
+ end
71
+
72
+ This is what `html_safe!` does and this is what Brut does internally. Brut doesn't monkey-patch `String`. A `String` is always
73
+ considered unsafe, and an `HTMLSafeString` is always considered safe.
74
+
75
+ Both of these methods are verbose, but this is on purpose. You generally don't want un-escaped HTML going into your HTML, but if you
76
+ do, you may find it better to create a component (see below), or use {Brut::FrontEnd::Component::Helpers#html_tag} to generate markup.
77
+
78
+ ### Pages Have Layouts
79
+
80
+ The page's ERB is rendered in the context of a *layout*. A Layout works similar to how it works in Rails. It's intended to hold HTML
81
+ needed by every page of your app. A minimal layout might look like so:
82
+
83
+ <!DOCTYPE html>
84
+ <html>
85
+ <head>
86
+ <meta charset="utf-8">
87
+ <%= component(Brut::FrontEnd::Components::PageIdentifier.new(self.page_name)) %>
88
+ <link rel="preload" as="style" href="<%= asset_path('/css/styles.css') %>">
89
+ <link rel="stylesheet" href="<%= asset_path('/css/styles.css') %>">
90
+ <script defer src="<%= asset_path('/js/app.js') %>"></script>
91
+ </head>
92
+ <body>
93
+ <%= yield %>
94
+ </body>
95
+ </html>
96
+
97
+ The layout is rendered in the context of the page class, so every page must provide whatever features are needed by the layout. This
98
+ is usually done by adding globally-used functions to `AppPage`, which is the base class of all your app's pages.
99
+
100
+ A page can use another layout by overriding {Brut::FrontEnd::Page#layout}. The string returned must match a file in
101
+ `app/src/front_end/layouts/`.
102
+
103
+ ### There Are No Partials
104
+
105
+ Rails partials are not part of ERB, and Brut does not include this feature. Instead, you would use *Components*.
106
+
107
+ ## Decompose and Re-Use with Components
108
+
109
+ *Components* in Brut are very similar to the View Components library, though somewhat simpler. A component is a class and an ERB
110
+ template. That template is rendered in the context of an instance of the class. That class is created the same way a page is and has
111
+ a `render` method that works just like a page's.
112
+
113
+ This is all because {Brut::FrontEnd::Page} extends {Brut::FrontEnd::Component}. A page adds the concept of a layout, but generally a
114
+ page and a component are the same thing.
115
+
116
+ ### Using Components
117
+
118
+ The main difference from your perspective is that generally *you* create instances of components. This means that your page must have
119
+ access to any data a component needs. When you've created your component instance, use {Brut::FrontEnd::Component#component} to
120
+ render it:
121
+
122
+ <%= component(Button.new(type: :danger, label: "Cancel Subscription")) %>
123
+
124
+ ### Components with Templates
125
+
126
+ The most common way to use a component is with an ERB template. It is expected to be in `app/src/front_end/components`. For the
127
+ hypothetical `Button` component above, Brut would expect `app/src/front_end/components/button.html.erb` to exist as a template.
128
+
129
+ Just like a page, the component's ERB is rendered in the context of the component only.
130
+
131
+ Sometimes, components are simple enough that you don't need HTML.
132
+
133
+ ### Components That Render Themselves
134
+
135
+ While overriding `render` in a page is generally discouraged, {Brut::FrontEnd::Component#render} can be overridden if you want to
136
+ generate HTML yourself. The best way to do that is with {Brut::FrontEnd::Component::Helpers#html_tag}, though you can always return a
137
+ `String` or {Brut::FrontEnd::Templates::HTMLSafeString} that you've created yourself. Just remeber that all `String` instances will be
138
+ HTML-escaped.
139
+
140
+ As an example, here is how you might have a Markdown component that renders Markdown as HTML:
141
+
142
+ class MarkdownComponent < AppComponent
143
+ def initialize(markdown:)
144
+ @markdown = markdown
145
+ @renderer = Redcarpet::Markdown.new(
146
+ Redcarpet::Render::HTML.new(
147
+ filter_html: true,
148
+ no_images: true,
149
+ no_styles: true,
150
+ safe_links_only: true,
151
+ ),
152
+ fenced_code_blocks: true,
153
+ autolink: true,
154
+ quote: true,
155
+ )
156
+ end
157
+
158
+ def render
159
+ html_safe!(@renderer.render(@markdown.to_s))
160
+ end
161
+ end
162
+
163
+
164
+ ### Global Components
165
+
166
+ While a component is just a class with an initializer, sometimes you need a component that is generally useful on any page, but that
167
+ you don't want to initialize. For example, if your component needs access to the flash, but your page does not, you don't want your
168
+ page to require a flash just to pass to the component. In that case, a *global component* can be used and Brut will instantiate it.
169
+
170
+ {Brut::FrontEnd::Component#component} can be given a class, and Brut will use keyword injection to create it. Consider a generic
171
+ flash component:
172
+
173
+ class GlobalFlash < AppComponent
174
+
175
+ attr_reader :flash
176
+
177
+ def initialize(flash:)
178
+ @flash = flash
179
+ end
180
+ end
181
+
182
+ You can use this anywhere like so:
183
+
184
+ <%= component(GlobalFlash) %>
185
+
186
+ Because the flash is availble from the {Brut::FrontEnd::RequestContext}, Brut can create this component when needed.
187
+
188
+ ## Testing Pages and Components
189
+
190
+ Pages and Components are classes with a constructor you create and a well-defined primary method called `render`. This means you can
191
+ test them conventionally, since they can be directly created in your tests.
192
+
193
+ That said, you likely want to test the generated HTML and not the methods of the class. All tests of a page or component have the
194
+ methods in {Brut::SpecSupport::ComponentSupport} available. Of particular interest is
195
+ {Brut::SpecSupport::ComponentSupport#render_and_parse}.
196
+
197
+ This method accepts an instance of a page or component, parses the resulting HTML with Nokogiri, and returns a
198
+ {Brut::SpecSupport::EnhancedNode}, which wraps a `Nokogiri::XML::Node` or `Nokogiri::XML::Element`, depending on what was parsed. You
199
+ can then use Nokogiri's API locate elements and assert on them.
200
+
201
+ To keep close to the web platform, it's recommended to use CSS selectors either via {Brut::SpecSupport::EnhancedNode#e} or {Brut::SpecSupport::EnhancedNode#e!} (both of which wrap Nokogiri's `css` method).
202
+
203
+ Instead of creating another API for accessing HTML content, the Nokogiri API should be used directly. You can create custom matchers
204
+ as needed for common assertions. Brut includes a few that you will find useful:
205
+
206
+ * `have_link_to`
207
+ * `have_html_attribute`
208
+ * `have_i18n_string`
209
+
210
+
@@ -0,0 +1,59 @@
1
+ # Route and Page Hooks
2
+
3
+ Route and page hooks allow you to perform logic or redirect the visitor before a page is rendered or action handled.
4
+
5
+ ## Route Hooks
6
+
7
+ Route hooks are objects that are run before or after a request has been handled. They are useful for setting up cross-cutting code
8
+ that you don't want to have inside a page or handler.
9
+
10
+ To use one, call either {Brut::Framework::App.before} or {Brut::Framework::App.after}, passing it the *name* of a class to use as the
11
+ hook (i.e. a `String`).
12
+
13
+ Then, implement that class, extending {Brut::FrontEnd::RouteHook}, and provide either {Brut::FrontEnd::RouteHook#before} or {Brut::FrontEnd::RouteHook#after}. As discussed in {file:doc-src/keyword-injection.md Keyword Injection}, your hook can be passed some managed values to allow it to work.
14
+
15
+ In general, a hook will allow the request to continue or not, but using one of the following methods as the return value:
16
+
17
+ * {Brut::FrontEnd::HandlingResults#redirect_to} to redirect the user instead of rendering the page or handling the request.
18
+ * {Brut::FrontEnd::HandlingResults#http_status} to return an HTTP status instead of rendering the page or handling the request.
19
+ * {Brut::FrontEnd::RouteHook#continue} to proceed with the request.
20
+
21
+ ## Page Hooks
22
+
23
+ Sometimes, the behavior you want to manage before a page is rendered is specific to a page and not cross-cutting. Because a page
24
+ exepcts to render HTML, you cannot easily put such code in your page class.
25
+
26
+ If you implement {Brut::FrontEnd::Page#before_render}, you can skip page rendering entirely and redirect the user or send an error. A
27
+ good example of this would be a set of admin pages where the logged-in site visitor must possess some roles in order to see the page.
28
+
29
+ A page hook expects one of these return values:
30
+
31
+ * `URI` - redirect the visitor instead of rendering the page.
32
+ * {Brut::FrontEnd::HttpStatus} - Send the browser this status code instead of rendering the page.
33
+ * Anything else - render the page as normal
34
+
35
+ Thus, the lifecycle of a page is:
36
+
37
+ 1. "Before" Route Hooks
38
+ 2. Page Initializer, injected as described in {file:doc-src/keyword-injection.md}
39
+ 3. Page's `before_render`, called with no arguments.
40
+ 4. Page's ERB generates HTML
41
+ 5. "After" Route Hooks
42
+
43
+ ## Handler Hooks
44
+
45
+ Like page hooks, handler hooks are called before handling logic. Implement `before_handle`. It's arguments must be a subset of the
46
+ arguments passed to `handle`. Thus, any value needed by `before_handle` must be declared as a keyword argument to `handle` as well.
47
+
48
+ If `before_handle` returns `nil`, `handle` is then called. Otherwise, `handle` is skipped and the return value of `before_handle` is
49
+ interpreted as the return value of `handle`. See {Brut::FrontEnd::Handler#handle}.
50
+
51
+ This makes the lifecycle of a handler as such:
52
+
53
+ 1. "Before" Route Hooks
54
+ 2. Handler Initializer, called with no argument.
55
+ 3. Handler's `handle!`, injected with arguments as described in {file:doc-src/keyword-injections.md}
56
+ 1. `handle!` calls `before_handle`, passing the arguments in.
57
+ 2. `handle!` calls `handle`, passing the arguments in.
58
+ 4. "After" Route Hooks
59
+
data/docs-todo.md ADDED
@@ -0,0 +1,32 @@
1
+ # DOCS strategy
2
+
3
+ ## Four types:
4
+
5
+ * Tutorials
6
+ * HOWTO
7
+ * Explanations
8
+ * Reference
9
+
10
+ Ideally, these can interlink, espeically to avoid reference having to have too much stuff in it.
11
+
12
+ ## Tutorials
13
+
14
+ * SHORT - Making a blog from zero to done
15
+ * LONG - Agile Web Development with Brut
16
+
17
+ ## HOWTO
18
+
19
+ * Create, validate, and process a form
20
+ * Implement Omniauth
21
+ * ???
22
+
23
+ ## Explanations
24
+
25
+ * Brut overall architecture
26
+ * Brut guiding principles
27
+ * ???
28
+
29
+ ## Reference
30
+
31
+ * API Docs
32
+
@@ -1,6 +1,10 @@
1
1
  require_relative "../factory_bot"
2
2
  module Brut
3
- module Backend
3
+ module BackEnd
4
+ # Base class and manager of Seed Data for the app. Seed Data is data used for development. It is not for populating e.g.
5
+ # reference data or other stuff in production.
6
+ #
7
+ # Seed Data uses FactoryBot.
4
8
  class SeedData
5
9
  def self.inherited(seed_data_klass)
6
10
  @classes ||= []
@@ -1,3 +1,3 @@
1
1
  class Brut::BackEnd::Validators
2
- autoload(:FormValidator, "brut/back_end/validators/form_validator")
2
+ autoload(:FormValidator, "brut/back_end/validators/form_validator")
3
3
  end
@@ -1,13 +1,38 @@
1
- # Subclass this in your back-end to create a server-side
2
- # validator for your form. This provides for a much
3
- # richer set of validations than you get from the browser, but
4
- # works basically the same way.
1
+ # Provides a very light DSL to declaring server-side validations for your
2
+ # {Brut::FrontEnd::Form} subclass. Unlike Active Record, these validations aren't mixed-into another object.
3
+ # Your subclass of this class is a standalone object that will operate on a form.
4
+ #
5
+ # @example
6
+ # # Suppose you are creating a widget with a name and description.
7
+ # # The form requires name, but not description, however if
8
+ # # the user initiates a "publish" action from that form, the description is
9
+ # # required. This cannot be managed with HTML alone.
10
+ # class WidgetPublishValidator < Brut::BackEnd::Validators::FormValidator
11
+ # validate :description, required: true, minlength: 10
12
+ # end
13
+ #
14
+ # # Then, in your back-end logic somewhere
15
+ #
16
+ # validator = WidgetPublishValidator.new
17
+ # validator.validate(form)
18
+ # if form.constraint_violations?
19
+ # # return back to the user
20
+ # else
21
+ # # proceed with business logic
22
+ # end
23
+ #
5
24
  class Brut::BackEnd::Validators::FormValidator
6
- def self.validate(attribute,options)
25
+ # Called inside the subclass body to indicate that a given form input should be validated based on the given options
26
+ # @param [String|Symbol] input_name name of the input of the form
27
+ # @param [Hash] options options describing the validation to perform.
28
+ def self.validate(input_name,options)
7
29
  @@validations ||= {}
8
- @@validations[attribute] = options
30
+ @@validations[input_name] = options
9
31
  end
10
32
 
33
+ # Validate the given form, calling {Brut::FrontEnd::Form#server_side_constraint_violation} on each validate failure found.
34
+ #
35
+ # @param [Brut::FrontEnd::Form] form the form to validate
11
36
  def validate(form)
12
37
  @@validations.each do |attribute,options|
13
38
  value = form.send(attribute)
data/lib/brut/cli/app.rb CHANGED
@@ -1,9 +1,28 @@
1
1
  require "optparse"
2
2
  require_relative "../junk_drawer"
3
+
4
+ # Base class for all Brut-powered CLI Apps. Your subclass will call or override methods to declare the UI of your CLI app, including
5
+ # the commands it provides and options it recognizes. These mostly help to provide command-line documentation for your app, but also
6
+ # provide basic help with accessing the command line arguments and options. Internally, this uses Ruby's `OptionParser`.
7
+ #
8
+ # The types of CLIs this framework supports are *command suites* where a single CLI app has several subcommands, similar to `git`. As
9
+ # such, the CLI has several parts that you can configure:
10
+ #
11
+ # ```
12
+ # cli_app [global options] «subcommand» [command options] [arguments]
13
+ # ```
14
+ #
15
+ # * Global options appear between the CLI app's executable and the name of the subcommand. These affect any command. A common example is `--log-level`. These are configured with {Brut::CLI::App.on} or {Brut::CLI::App.option_parser}.
16
+ # * Subcommand is a single string representing the command to execute. The available commands are returned by {Brut::CLI::App.commands} although it's more conventional to declare inner classes of your app that extend {Brut::CLI::Command}.
17
+ # * Command options appear after the subcommand and apply only to that sub command. They are declared with {Brut::CLI::Command.on} or {Brut::CLI::Command.opts}.
18
+ # * Arguments are any additional values present on the command line. They are defined per-command and can be documented via {Brut::CLI::Command.args}.
3
19
  class Brut::CLI::App
4
20
  include Brut::CLI::ExecutionResults
5
21
  include Brut::I18n::ForCLI
6
22
 
23
+ # Returns a list of {Brut::CLI::Command} classes that each represent the subcommands your CLI app accepts.
24
+ # By default, this will look for all internal classes that extend {Brut::CLI::Command} and use them as your subcommands.
25
+ # This means that you don't need to override this method and can instead define classes inside your app subclass.
7
26
  def self.commands
8
27
  self.constants.map { |name|
9
28
  self.const_get(name)
@@ -11,6 +30,11 @@ class Brut::CLI::App
11
30
  constant.kind_of?(Class) && constant.ancestors.include?(Brut::CLI::Command) && constant.instance_methods.include?(:execute)
12
31
  }
13
32
  end
33
+
34
+ # Call this to set the one-line description of your command line app.
35
+ #
36
+ # @param new_description [String] When present, sets the description of this app. When omitted, returns the current description.
37
+ # @return [String] the current description (if called with no parameters)
14
38
  def self.description(new_description=nil)
15
39
  if new_description.nil?
16
40
  return @description.to_s
@@ -18,21 +42,77 @@ class Brut::CLI::App
18
42
  @description = new_description
19
43
  end
20
44
  end
45
+
46
+ # Call this for each environment variable your *app* responds to. These would be variables that affect any of the subcommands. For
47
+ # command-specific environment variables, see {Brut::CLI::Command.env_var}.
48
+ #
49
+ # @param var_name [String] Declares that this app recognizes this environment variable.
50
+ # @param purpose [String] An explanation for how this environment variable affects the app. Used in documentation.
51
+ def self.env_var(var_name,purpose:)
52
+ env_vars[var_name] = purpose
53
+ end
54
+
55
+ # Access all configured environment variables.
56
+ # @!visibility private
57
+ def self.env_vars
58
+ @env_vars ||= {
59
+ "BRUT_CLI_RAISE_ON_ERROR" => "if set, shows backtrace on errors"
60
+ }
61
+ end
62
+
63
+ # Specify the default command to use when no subcommand is given.
64
+ #
65
+ # @param new_command_name [String] if present, sets the name of the command to run when none is given on the command line. When omitted, returns the currently configured name. The default is `help`.
21
66
  def self.default_command(new_command_name=nil)
22
67
  if new_command_name.nil?
23
68
  return @default_command || "help"
24
69
  else
25
- @default_command = new_command_name.nil? ? nil : new_command_name.to_s
70
+ @default_command = new_command_name.to_s
26
71
  end
27
72
  end
73
+
74
+ # Provides access to an `OptionParser` you can use to declare flags and switches that should be accepted globally. The way to use
75
+ # this is to call `.on` and provide a description for an option as you would to `OptionParser`. The only difference is that you
76
+ # should not pass a block to this. When the command line is parsed, the results will be placed into a {Brut::CLI::Options}
77
+ # instance made available to your command.
78
+ #
79
+ # @return [OptionParser]
80
+ #
81
+ # @example
82
+ #
83
+ # class MyApp < Brut::CLI::App
84
+ #
85
+ # opts.on("--dry-run","Don't change anything, just pretend")
86
+ #
87
+ # end
28
88
  def self.opts
29
89
  self.option_parser
30
90
  end
91
+
92
+ # Returns the configured `OptionParser` used to parse global portion of the command line.
93
+ # If you don't want to call {.opts}, you can create and return
94
+ # a fully-formed `OptionParser` by overriding this method. By default, it will create one with a conventional banner.
95
+ #
96
+ # @return [OptionParser]
31
97
  def self.option_parser
32
98
  @option_parser ||= OptionParser.new do |opts|
33
99
  opts.banner = "%{app} %{global_options} commands [command options] [args]"
34
100
  end
35
101
  end
102
+
103
+ # Call this if your CLI requires a project environment as context for what it does. For example, a command to analyze the database
104
+ # needs to know if it should operate on development, test, or production. When called, this will do a few things:
105
+ #
106
+ # * Your app will recognize `--env=ENVIRONMENT` as a global option
107
+ # * Your app will recognize the `RACK_ENV` environment variable.
108
+ #
109
+ # When your app executes, the project environment will be determined as follows:
110
+ #
111
+ # 1. If `--env` was on the command line, that is the environment used
112
+ # 2. If `RACK_ENV` is set in the environment, that is used
113
+ # 3. Otherwise the value given to the `default:` parameter of this method is used.
114
+ #
115
+ # @param default [String] name of the environment to use if none was specified. `nil` should be used to require the environment to be specified explicitly.
36
116
  def self.requires_project_env(default: "development")
37
117
  default_message = if default.nil?
38
118
  ""
@@ -42,16 +122,26 @@ class Brut::CLI::App
42
122
  opts.on("--env=ENVIRONMENT","Project environment#{default_message}")
43
123
  @default_env = ENV["RACK_ENV"] || default
44
124
  @requires_project_env = true
125
+ self.env_var("RACK_ENV",purpose: "default project environment when --env is omitted")
45
126
  end
46
127
 
128
+ # Returns the default project env, based on the logic described in {.requires_project_env}
47
129
  def self.default_env = @default_env
130
+ # Returns true if this app requires a project env
48
131
  def self.requires_project_env? = @requires_project_env
49
132
 
133
+ # Call this if your app must operate before the Brut framework starts up.
50
134
  def self.configure_only!
51
135
  @configure_only = true
52
136
  end
53
137
  def self.configure_only? = !!@configure_only
54
138
 
139
+ # Create the App. This is called by {Brut::CLI::AppRunner}.
140
+ #
141
+ # @param [Brut::CLI::Options] global_options global options specified on the command line
142
+ # @param [Brut::CLI::Output] out IO to use to send output to the standard output
143
+ # @param [Brut::CLI::Output] err IO to use to send output to the standard error
144
+ # @param [Brut::CLI::Executor] executor Class to use to execute child processes instead of e.g. `system`.
55
145
  def initialize(global_options:,out:,err:,executor:)
56
146
  @global_options = global_options
57
147
  @out = out
@@ -62,12 +152,14 @@ class Brut::CLI::App
62
152
  end
63
153
  end
64
154
 
155
+ # @!visibility private
65
156
  def set_env_if_needed
66
157
  if self.class.requires_project_env?
67
158
  ENV["RACK_ENV"] = options.env
68
159
  end
69
160
  end
70
161
 
162
+ # @!visibility private
71
163
  def load_env(project_root:)
72
164
  if !ENV["RACK_ENV"]
73
165
  ENV["RACK_ENV"] = "development"
@@ -81,15 +173,19 @@ class Brut::CLI::App
81
173
  end
82
174
  end
83
175
 
176
+ # Called before anything else happens. You can override this to perform any setup or other checking before Brut is started up.
84
177
  def before_execute
85
178
  end
86
179
 
180
+ # Called after all setup has been executed. Brut will have been started/loaded. This will *not* be called if anything
181
+ # caused execution to be aborted.
87
182
  def after_bootstrap
88
183
  end
89
184
 
90
- def configure!
91
- end
92
-
185
+ # Executes the command. Called by {Brut::CLI::AppRunner}.
186
+ #
187
+ # @param [Brut::CLI::Command] command the command to run, based on what was on the command line
188
+ # @param [Pathname] project_root root of the Brut app's project files.
93
189
  def execute!(command,project_root:)
94
190
  before_execute
95
191
  set_env_if_needed
@@ -1,13 +1,20 @@
1
1
  module Brut
2
2
  module CLI
3
+ # Manages the execution of a CLI app that extends {Brut::CLI::App}. Generally you won't use this class directly, but will call
4
+ # {Brut::CLI.app}.
3
5
  class AppRunner
4
6
  include Brut::CLI::ExecutionResults
5
7
 
8
+ # Create the app runner with a class and project root
9
+ #
10
+ # @param [Class] app_klass The class of your app that extends {Brut::CLI::App}
11
+ # @param [Pathname] project_root path to the root of the project
6
12
  def initialize(app_klass:,project_root:)
7
13
  @app_klass = app_klass
8
14
  @project_root = project_root
9
15
  end
10
16
 
17
+ # Runs the app, based on the CLI arguments and UNIX environment provided at the time of execution.
11
18
  def run!
12
19
  app_klass = @app_klass
13
20
  out = Brut::CLI::Output.new(io: $stdout,prefix: "[ #{$0} ] ")
@@ -36,7 +43,7 @@ module Brut
36
43
  show_global_help(app_klass:,out:)
37
44
  else
38
45
  command_option_parser = command_klass.option_parser
39
- show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
46
+ show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:,app_klass:)
40
47
  end
41
48
  end
42
49
  return result.to_i
@@ -52,7 +59,11 @@ module Brut
52
59
  end
53
60
 
54
61
  command_argv = remaining_argv[1..-1] || []
55
- args = command_option_parser.parse!(command_argv,into:command_options)
62
+ begin
63
+ args = command_option_parser.parse!(command_argv,into:command_options)
64
+ rescue OptionParser::ParseError => ex
65
+ raise Brut::CLI::InvalidOption.new(ex, context: command_klass)
66
+ end
56
67
 
57
68
  cli_app = app_klass.new(global_options:, out:, err:, executor:)
58
69
  cmd = command_klass.new(command_options:Brut::CLI::Options.new(command_options),global_options:, args:, out:, err:, executor:)
@@ -133,13 +144,22 @@ module Brut
133
144
  option_parser.summarize do |line|
134
145
  out.puts_no_prefix line
135
146
  end
147
+ out.puts_no_prefix
148
+ out.puts_no_prefix "ENVIRONMENT VARIABLES"
149
+ out.puts_no_prefix
150
+ max_length = app_klass.env_vars.keys.map(&:length).max
151
+ printf_string = " %-#{max_length}s - %s\n"
152
+ app_klass.env_vars.keys.sort.each do |var_name|
153
+ out.printf_no_prefix(printf_string,var_name,app_klass.env_vars[var_name])
154
+ end
155
+ out.puts_no_prefix
136
156
  if app_klass.commands.any?
137
157
  out.puts_no_prefix
138
158
  out.puts_no_prefix "COMMANDS"
139
159
  out.puts_no_prefix
140
160
  max_length = [ 4, app_klass.commands.map { |_| _.command_name.to_s.length }.max ].max
141
161
  printf_string = " %-#{max_length}s - %s%s\n"
142
- printf printf_string, "help", "Get help on a command",""
162
+ out.printf_no_prefix printf_string, "help", "Get help on a command",""
143
163
  app_klass.commands.sort_by(&:command_name).each do |command|
144
164
  default_message = if command.name_matches?(app_klass.default_command)
145
165
  " (default)"
@@ -152,13 +172,13 @@ module Brut
152
172
  else
153
173
  command.description
154
174
  end
155
- printf printf_string, command.command_name, command.description, default_message
175
+ out.printf_no_prefix printf_string, command.command_name, command.description, default_message
156
176
  end
157
177
  end
158
178
  out.puts_no_prefix
159
179
  end
160
180
 
161
- def show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:)
181
+ def show_command_help(global_option_parser:,command_option_parser:,command_klass:,out:,app_klass:)
162
182
  banner = command_option_parser.banner % {
163
183
  app: $0,
164
184
  global_options: global_option_parser.top.list.length == 0 ? "" : "[global options]",
@@ -179,6 +199,16 @@ module Brut
179
199
  global_option_parser.summarize do |line|
180
200
  out.puts_no_prefix line
181
201
  end
202
+ out.puts_no_prefix
203
+ out.puts_no_prefix "ENVIRONMENT VARIABLES"
204
+ out.puts_no_prefix
205
+ all_vars = app_klass.env_vars.merge(command_klass.env_vars)
206
+ max_length = all_vars.keys.map(&:length).max
207
+ printf_string = " %-#{max_length}s - %s\n"
208
+ all_vars.keys.sort.each do |var_name|
209
+ out.printf_no_prefix(printf_string,var_name,all_vars[var_name])
210
+ end
211
+ out.puts_no_prefix
182
212
  if command_option_parser.top.list.length > 0
183
213
  out.puts_no_prefix
184
214
  out.puts_no_prefix "COMMAND OPTIONS"
@@ -208,11 +238,14 @@ module Brut
208
238
  option_parser.on("--verbose","Set log level to '#{log_levels[0]}', which will produce maximum output") do
209
239
  ENV["LOG_LEVEL"] = log_levels[0]
210
240
  end
241
+ app_klass.env_var("LOG_LEVEL",purpose: "log level if --log-level or --verbose is omitted")
211
242
 
212
243
  hash = {}
213
244
  remaining_argv = option_parser.order!(into:hash)
214
245
  global_options = Brut::CLI::Options.new(hash)
215
246
  [ continue_execution, remaining_argv, global_options, option_parser ]
247
+ rescue OptionParser::ParseError => ex
248
+ raise Brut::CLI::InvalidOption.new(ex, context: :global)
216
249
  end
217
250
  end
218
251
  end