brut 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +6 -0
- data/Gemfile.lock +66 -1
- data/README.md +36 -0
- data/Rakefile +22 -0
- data/brut.gemspec +7 -0
- data/doc-src/architecture.md +102 -0
- data/doc-src/assets.md +98 -0
- data/doc-src/forms.md +214 -0
- data/doc-src/handlers.md +83 -0
- data/doc-src/javascript.md +265 -0
- data/doc-src/keyword-injection.md +183 -0
- data/doc-src/pages.md +210 -0
- data/doc-src/route-hooks.md +59 -0
- data/docs-todo.md +32 -0
- data/lib/brut/back_end/seed_data.rb +5 -1
- data/lib/brut/back_end/validator.rb +1 -1
- data/lib/brut/back_end/validators/form_validator.rb +31 -6
- data/lib/brut/cli/app.rb +100 -4
- data/lib/brut/cli/app_runner.rb +38 -5
- data/lib/brut/cli/apps/build_assets.rb +4 -6
- data/lib/brut/cli/apps/db.rb +2 -7
- data/lib/brut/cli/apps/scaffold.rb +413 -7
- data/lib/brut/cli/apps/test.rb +14 -1
- data/lib/brut/cli/command.rb +141 -6
- data/lib/brut/cli/error.rb +30 -3
- data/lib/brut/cli/execution_results.rb +44 -6
- data/lib/brut/cli/executor.rb +21 -1
- data/lib/brut/cli/options.rb +29 -2
- data/lib/brut/cli/output.rb +47 -0
- data/lib/brut/cli.rb +26 -0
- data/lib/brut/factory_bot.rb +2 -0
- data/lib/brut/framework/app.rb +38 -5
- data/lib/brut/framework/config.rb +97 -54
- data/lib/brut/framework/container.rb +97 -33
- data/lib/brut/framework/errors/abstract_method.rb +3 -7
- data/lib/brut/framework/errors/missing_parameter.rb +12 -0
- data/lib/brut/framework/errors/no_class_for_path.rb +25 -0
- data/lib/brut/framework/errors/not_found.rb +12 -2
- data/lib/brut/framework/errors/not_implemented.rb +14 -0
- data/lib/brut/framework/errors.rb +18 -0
- data/lib/brut/framework/fussy_type_enforcement.rb +17 -12
- data/lib/brut/framework/mcp.rb +106 -49
- data/lib/brut/framework/project_environment.rb +9 -0
- data/lib/brut/framework.rb +1 -0
- data/lib/brut/front_end/asset_metadata.rb +7 -1
- data/lib/brut/front_end/component.rb +129 -38
- data/lib/brut/front_end/components/constraint_violations.rb +57 -0
- data/lib/brut/front_end/components/form_tag.rb +23 -32
- data/lib/brut/front_end/components/i18n_translations.rb +34 -1
- data/lib/brut/front_end/components/input.rb +3 -0
- data/lib/brut/front_end/components/inputs/csrf_token.rb +2 -0
- data/lib/brut/front_end/components/inputs/radio_button.rb +41 -0
- data/lib/brut/front_end/components/inputs/select.rb +26 -2
- data/lib/brut/front_end/components/inputs/text_field.rb +27 -5
- data/lib/brut/front_end/components/inputs/textarea.rb +24 -4
- data/lib/brut/front_end/components/locale_detection.rb +8 -1
- data/lib/brut/front_end/components/page_identifier.rb +2 -0
- data/lib/brut/front_end/components/time.rb +95 -0
- data/lib/brut/front_end/components/traceparent.rb +22 -0
- data/lib/brut/front_end/download.rb +11 -0
- data/lib/brut/front_end/flash.rb +32 -0
- data/lib/brut/front_end/form.rb +109 -106
- data/lib/brut/front_end/forms/constraint_violation.rb +16 -11
- data/lib/brut/front_end/forms/input.rb +30 -42
- data/lib/brut/front_end/forms/input_declarations.rb +90 -0
- data/lib/brut/front_end/forms/input_definition.rb +45 -30
- data/lib/brut/front_end/forms/radio_button_group_input.rb +46 -0
- data/lib/brut/front_end/forms/radio_button_group_input_definition.rb +29 -0
- data/lib/brut/front_end/forms/select_input.rb +47 -0
- data/lib/brut/front_end/forms/select_input_definition.rb +27 -0
- data/lib/brut/front_end/forms/validity_state.rb +23 -9
- data/lib/brut/front_end/handler.rb +27 -8
- data/lib/brut/front_end/handlers/csp_reporting_handler.rb +4 -2
- data/lib/brut/front_end/handlers/instrumentation_handler.rb +99 -0
- data/lib/brut/front_end/handlers/locale_detection_handler.rb +9 -4
- data/lib/brut/front_end/handlers/missing_handler.rb +9 -0
- data/lib/brut/front_end/handling_results.rb +14 -4
- data/lib/brut/front_end/http_method.rb +13 -1
- data/lib/brut/front_end/http_status.rb +10 -0
- data/lib/brut/front_end/layouts/_internal.html.erb +68 -0
- data/lib/brut/front_end/middleware.rb +5 -0
- data/lib/brut/front_end/middlewares/annotate_brut_owned_paths.rb +15 -0
- data/lib/brut/front_end/middlewares/favicon.rb +16 -0
- data/lib/brut/front_end/middlewares/open_telemetry_span.rb +12 -0
- data/lib/brut/front_end/middlewares/reload_app.rb +27 -9
- data/lib/brut/front_end/page.rb +50 -11
- data/lib/brut/front_end/pages/_missing_page.html.erb +17 -0
- data/lib/brut/front_end/pages/missing_page.rb +36 -0
- data/lib/brut/front_end/request_context.rb +117 -8
- data/lib/brut/front_end/route_hook.rb +43 -1
- data/lib/brut/front_end/route_hooks/age_flash.rb +3 -2
- data/lib/brut/front_end/route_hooks/csp_no_inline_scripts.rb +8 -0
- data/lib/brut/front_end/route_hooks/csp_no_inline_styles_or_scripts.rb +18 -10
- data/lib/brut/front_end/route_hooks/locale_detection.rb +11 -5
- data/lib/brut/front_end/route_hooks/setup_request_context.rb +7 -4
- data/lib/brut/front_end/routing.rb +138 -31
- data/lib/brut/front_end/session.rb +86 -7
- data/lib/brut/front_end/template.rb +17 -2
- data/lib/brut/front_end/templates/block_filter.rb +4 -3
- data/lib/brut/front_end/templates/erb_parser.rb +1 -1
- data/lib/brut/front_end/templates/escapable_filter.rb +2 -0
- data/lib/brut/front_end/templates/html_safe_string.rb +30 -2
- data/lib/brut/front_end/templates/locator.rb +60 -0
- data/lib/brut/i18n/base_methods.rb +4 -0
- data/lib/brut/i18n.rb +1 -0
- data/lib/brut/instrumentation/logger_span_exporter.rb +75 -0
- data/lib/brut/instrumentation/open_telemetry.rb +107 -0
- data/lib/brut/instrumentation.rb +4 -6
- data/lib/brut/junk_drawer.rb +54 -4
- data/lib/brut/sinatra_helpers.rb +42 -38
- data/lib/brut/spec_support/clock_support.rb +6 -0
- data/lib/brut/spec_support/component_support.rb +53 -26
- data/lib/brut/spec_support/e2e_test_server.rb +82 -0
- data/lib/brut/spec_support/enhanced_node.rb +45 -0
- data/lib/brut/spec_support/general_support.rb +14 -3
- data/lib/brut/spec_support/handler_support.rb +2 -0
- data/lib/brut/spec_support/matcher.rb +6 -3
- data/lib/brut/spec_support/matchers/be_page_for.rb +3 -2
- data/lib/brut/spec_support/matchers/have_constraint_violation.rb +22 -9
- data/lib/brut/spec_support/matchers/have_html_attribute.rb +9 -2
- data/lib/brut/spec_support/matchers/have_i18n_string.rb +24 -0
- data/lib/brut/spec_support/matchers/have_link_to.rb +14 -0
- data/lib/brut/spec_support/matchers/have_redirected_to.rb +30 -0
- data/lib/brut/spec_support/matchers/have_rendered.rb +4 -11
- data/lib/brut/spec_support/matchers/have_returned_http_status.rb +16 -8
- data/lib/brut/spec_support/rspec_setup.rb +182 -0
- data/lib/brut/spec_support.rb +8 -3
- data/lib/brut/version.rb +2 -1
- data/lib/brut.rb +28 -5
- data/lib/sequel/extensions/brut_instrumentation.rb +5 -27
- data/lib/sequel/extensions/brut_migrations.rb +18 -8
- data/lib/sequel/plugins/created_at.rb +2 -0
- data/lib/sequel/plugins/external_id.rb +39 -1
- data/lib/sequel/plugins/find_bang.rb +4 -1
- metadata +140 -13
- data/lib/brut/back_end/action.rb +0 -3
- data/lib/brut/back_end/result.rb +0 -46
- data/lib/brut/front_end/components/timestamp.rb +0 -33
- data/lib/brut/instrumentation/basic.rb +0 -66
- data/lib/brut/instrumentation/event.rb +0 -19
- data/lib/brut/instrumentation/http_event.rb +0 -5
- 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
|
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,13 +1,38 @@
|
|
1
|
-
#
|
2
|
-
#
|
3
|
-
#
|
4
|
-
#
|
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
|
-
|
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[
|
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.
|
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
|
-
|
91
|
-
|
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
|
data/lib/brut/cli/app_runner.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|