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/handlers.md
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
# Handlers
|
2
|
+
|
3
|
+
In Brut, a *handler* responds to an HTTP request that *isn't* inteded to render a web page. Primarily, a handler is used to process a
|
4
|
+
form submission, but a handler can also respond to an Ajax request. A handler is like a controller in Rails, however in Brut, a
|
5
|
+
handler is a class you implement that has a method that receives arguments and a return value that controls the response (unlike controllers in Rails).
|
6
|
+
|
7
|
+
## Defining a Route to be Handled
|
8
|
+
|
9
|
+
There are three ways to define a route that requires a handler, `form`, `action`, and `path`.
|
10
|
+
|
11
|
+
|
12
|
+
class App < Brut::Framework::App
|
13
|
+
|
14
|
+
routes do
|
15
|
+
|
16
|
+
form "/new_widget"
|
17
|
+
action "/archive_widget/:id"
|
18
|
+
path "/payment_received", method: :get
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
`form` indicates you have a form you are going to render in HTML and process its submission. The use of `form` requires that you have
|
23
|
+
a {Brut::FrontEnd::Form} defined (see {file:doc-src/forms.md}) as well as a handler. The names are conventional based on the route.
|
24
|
+
In the example above, Brut will expect `NewWidgetForm` and `NewWidgetHandler` to exist.
|
25
|
+
|
26
|
+
You can create these with `bin/scaffold`:
|
27
|
+
|
28
|
+
bin/scaffold form NewWidget
|
29
|
+
|
30
|
+
`action` is used when you have a form to submit, but it has no user controls to collect the data to submit. This is akin to Rails'
|
31
|
+
`button_to` where the URL describes everything needed to handle a user's action. In the example above, you can imagine a form with a
|
32
|
+
method to `/archive_widget/<%= widget.id %>` that has a button labeled "Archive Widget". All that's needed is to submit to the
|
33
|
+
server.
|
34
|
+
|
35
|
+
In that case, only a handler is required. The name is again conventional. In this case, `ArchiveWidgetWithId`. You can create this
|
36
|
+
with `bin/scaffold`
|
37
|
+
|
38
|
+
bin/scaffold handler ArchiveWidthWithId
|
39
|
+
|
40
|
+
The last case, `path`, is for arbitray routes. It works the same way as `action`, but requires a `method:` to declare the HTTP
|
41
|
+
method.
|
42
|
+
|
43
|
+
## Implementing a Handler
|
44
|
+
|
45
|
+
Regardless of how you declare a route, all handlers must inherit {Brut::FrontEnd::Handler} (though realistically, they will inherit
|
46
|
+
`AppHandler`, which inherets `Brut::FrontEnd::Handler`) and implement `handle`.
|
47
|
+
|
48
|
+
`handle` can accept keyword arguments that are injected by Brut according to the rules outlined in {file:/doc-src/keyword-injection.md Keyword Injection}. Note that handlers that process forms should declare `form:` as a keyword argument. They will be given an instantiated instance of their form, based on the values in the form submission from the browser.
|
49
|
+
|
50
|
+
The return value of the method determines what will happen:
|
51
|
+
|
52
|
+
* `URI` - the visitor is redirected to this URI. Typically, you'd achieve this with the {Brut::FrontEnd::HandlingResults#redirect_to}
|
53
|
+
helper.
|
54
|
+
* {Brut::FrontEnd::Component} - this component's HTML is rendered. Note that since {Brut::FrontEnd::Page} is a subclass of
|
55
|
+
`Brut::FrontEnd::Component}, returning a page instance will render that entire page. This is useful when re-rendering a page with
|
56
|
+
form errors.
|
57
|
+
- You can also return a two-element array with the first element being a component and the second being a `Brut::FrontEnd::HttpStatus`. This will render the component's HTML but use the given status as the HTTP status.
|
58
|
+
* {Brut::FrontEnd::HttpStatus} - this status is returned with no content. Typically, you'd achieve this with the {Brut::FrontEnd::HandlingResults#http_status}
|
59
|
+
* {Brut::FrontEnd::Download} - a file will be downloaded
|
60
|
+
|
61
|
+
## Hooks
|
62
|
+
|
63
|
+
See {file:/doc-src/hooks.md} for more discussiong, but implementing `before_handle` will allow you to run code before `handle` is
|
64
|
+
called. This feature is mostly useful for a base class or module to share re-usable logic.
|
65
|
+
|
66
|
+
## Testing Handlers
|
67
|
+
|
68
|
+
Since a handler is just a class, you can test it conventionally, but there are a few things to keep in mind that can make testing your
|
69
|
+
handler easier.
|
70
|
+
|
71
|
+
First is that you should call `handle!`, not `handle`. The public interface of a handler is `handle!`—`handle` is a template method.
|
72
|
+
`handle!` will call `before_render`, so you should still call `handle!`, even if you are testing the logic in `before_render`.
|
73
|
+
|
74
|
+
You can also simplify your expectations with the following matchers:
|
75
|
+
|
76
|
+
* `have_redirected_to` - asserts that the handler redirected to the given URL. It can be given a `URI` or a page class.
|
77
|
+
* `have_rendered`- asserts that the handler renders the given component or page. It expects the page or component class.
|
78
|
+
* `have_returned_http_status` - asserts that the handler returned the given HTTP status. This works for a lot of return values that
|
79
|
+
the handler can return:
|
80
|
+
- If the handler returned a `URI`, the matcher will match on a 302 and fail otherwise
|
81
|
+
- If the handler returns an HTTP status code, the matcher's code must match (or the code must be omitted)
|
82
|
+
- If the handler returns anything else, the matcher will match on a 200 and fail otherwise
|
83
|
+
|
@@ -0,0 +1,265 @@
|
|
1
|
+
# JavaScript and Front End Behavior
|
2
|
+
|
3
|
+
Brut does not prevent you from using any front-end framework you like. You can certainly install React and reference it in
|
4
|
+
`app/src/front_end/javascript/index.js`. However, Brut would like to humbly request that you not do this.
|
5
|
+
|
6
|
+
Brut is based around server-generated HTML and the web platform. This means that Brut would like you to use custom elements for any
|
7
|
+
client-side behavior you need, and to do so with progressive enhancement.
|
8
|
+
|
9
|
+
To that end, Brut includes BrutJS, which is a set of custom elements and ancillary JavaScript you can use to build client-side
|
10
|
+
behavior.
|
11
|
+
|
12
|
+
By default, your `index.js` will look like this:
|
13
|
+
|
14
|
+
import { BrutCustomElements } from "brut-js"
|
15
|
+
import Example from "./Example"
|
16
|
+
|
17
|
+
document.addEventListener("DOMContentLoaded", () => {
|
18
|
+
BrutCustomElements.define()
|
19
|
+
Example.define()
|
20
|
+
})
|
21
|
+
|
22
|
+
`BrutCustomElements` and `BrutCustomElements.define()` will set up the custom elements bundled with Brut. `Example` shows you how to
|
23
|
+
build your own custom elements.
|
24
|
+
|
25
|
+
## Some Useful Brut Elements
|
26
|
+
|
27
|
+
Please refer to BrutJS's documentation for everything that is included, but here are a few highlights that you will find usefule.
|
28
|
+
|
29
|
+
### Client Side Form Support
|
30
|
+
|
31
|
+
{file:doc-src/forms.md Forms} outlines Brut's server-side form support. Brut provides custom elements to allow you to unify client
|
32
|
+
and server side constraint validations, and make the process a bit easier to manage with CSS.
|
33
|
+
|
34
|
+
First, surrounding a form with a `<brut-form>` will place `data-submitted` onto the `<form>` *only* when the user attempts to submit the
|
35
|
+
form. You can use this in your CSS to prevent showing error messages before a user has submitted.
|
36
|
+
|
37
|
+
Second, you can use `<brut-cv-messages>` and `<brut-cv>` to control the error messages that are shown when the browser detects
|
38
|
+
constraint violations. This works with `<brut-i18n-translation>` to show translated strings.
|
39
|
+
|
40
|
+
Consider this ERB
|
41
|
+
|
42
|
+
<label>
|
43
|
+
<%=
|
44
|
+
component(
|
45
|
+
Brut::FrontEnd::Components::Inputs::TextField.for_form_input(form:, input_name: :name)
|
46
|
+
)
|
47
|
+
%>
|
48
|
+
<span>Name</span>
|
49
|
+
<%= constraint_violations(form:,input_name: :name) %>
|
50
|
+
</label>
|
51
|
+
|
52
|
+
{Brut::FrontEnd::Component::Helpers#constraint_violations} will render the built-in {Brut::FrontEnd::Components::ConstraintViolations}
|
53
|
+
component. Along with `for_form_input`, the following HTML will be generated:
|
54
|
+
|
55
|
+
<label>
|
56
|
+
<input type="text" name="name" required>
|
57
|
+
<span>Name</span>
|
58
|
+
<brut-cv-messages input-name="name">
|
59
|
+
</brut-cv-messages>
|
60
|
+
</label>
|
61
|
+
|
62
|
+
When any element of the form causes a validity event to be fired, `<brut-form>` will locate the appropriate `<brut-cv-messages>` and
|
63
|
+
insert the appropriate `<brut-cv>` elements. Suppose the user submitted this form. Since the `name` input is required, the form
|
64
|
+
submission wouldn't happen, and the resulting HTML would look like so:
|
65
|
+
|
66
|
+
<label>
|
67
|
+
<input type="text" name="name" required>
|
68
|
+
<span>Name</span>
|
69
|
+
<brut-cv-messages input-name="name">
|
70
|
+
<brut-cv input-name="name" key="valueMissing"></brut-cv>
|
71
|
+
</brut-cv-messages>
|
72
|
+
</label>
|
73
|
+
|
74
|
+
Now, assuming your layout used `<brut-i18n-translation>` custom elements, for example like so:
|
75
|
+
|
76
|
+
<brut-i18n-translation key="general.cv.fe.valueMissing">%{field} is required</brut-i18n-translation>
|
77
|
+
|
78
|
+
The `<brut-cv>` custom element will find this and replace its `textContent`, resulting in the following HTML:
|
79
|
+
|
80
|
+
<label>
|
81
|
+
<input type="text" name="name" required>
|
82
|
+
<span>Name</span>
|
83
|
+
<brut-cv-messages input-name="name">
|
84
|
+
<brut-cv input-name="name" key="valueMissing">
|
85
|
+
This field is required
|
86
|
+
</brut-cv>
|
87
|
+
</brut-cv-messages>
|
88
|
+
</label>
|
89
|
+
|
90
|
+
You can now use CSS to style client-side validations *and* control the content shown to the user. If there are server-side constraint
|
91
|
+
violations, The `ConstraintViolations` component would render them (as well as server-generated translations), for example if a name
|
92
|
+
was given, but it's taken already, `ConstraintViolations` would render this HTML:
|
93
|
+
|
94
|
+
<label>
|
95
|
+
<input type="text" name="name" required value="foo">
|
96
|
+
<span>Name</span>
|
97
|
+
<brut-cv-messages input-name="name">
|
98
|
+
<brut-cv input-name="name" server-side>
|
99
|
+
This value has been taken
|
100
|
+
</brut-cv>
|
101
|
+
</brut-cv-messages>
|
102
|
+
</label>
|
103
|
+
|
104
|
+
### Confirming Dangerous Actions
|
105
|
+
|
106
|
+
Often, you want to use JavaScript to confirm the submission of a form whose action is considered dangerous to the user or hard to undo.
|
107
|
+
This can be achieved with `<brut-confirm-submit>`
|
108
|
+
|
109
|
+
<form>
|
110
|
+
<input type="text" name="name" required>
|
111
|
+
<brut-confirm-submit message="This will delete the app">
|
112
|
+
<button>Delete App</button>
|
113
|
+
</brut-confirm-submit>
|
114
|
+
</form>
|
115
|
+
|
116
|
+
By default, this will use the browser's built-in `window.confirm`, however you can also use a `<dialog>` element as well.
|
117
|
+
|
118
|
+
If the generated HTML includes a `<dialog>`, you can surround it with `<brut-confirmation-dialog>` to indicate it should be used for
|
119
|
+
confirmation. The `<dialog>` should have an `<h1>` where the message will be placed and two buttons, one with `value="ok"` and one
|
120
|
+
with `value="cancel"`.
|
121
|
+
|
122
|
+
|
123
|
+
<brut-confirmation-dialog>
|
124
|
+
<dialog>
|
125
|
+
<h1></h1>
|
126
|
+
<button value="ok">Do It!</button>
|
127
|
+
<button value="cancel">Nevermind</button>
|
128
|
+
</dialog>
|
129
|
+
</brut-confirmation-dialog>
|
130
|
+
|
131
|
+
When the `<brut-confirm-submit>`'s `<button>` is clicked, this `<dialog>` is shown with the message inserted. If the user hits the
|
132
|
+
button with `value` of `"ok"`, the form submission goes through. Otherwise, it doesn't. The dialog is then hidden.
|
133
|
+
|
134
|
+
### Ajax Form Submission
|
135
|
+
|
136
|
+
To submit a form via Ajax, you can use `<brut-ajax-submit>` around the `<button>` that should submit the form with Ajax. This element
|
137
|
+
attempts to provide a fault-tolerant user experience and will set various attributes on itself to allow you to change styling during
|
138
|
+
the various phases of the request.
|
139
|
+
|
140
|
+
If the submission works, it will fire a `brut:submitok` event that your custom code can receive and do whatever makes the most sense
|
141
|
+
in that case.
|
142
|
+
|
143
|
+
If the submission fails with a 422, your server should return a series of `<brut-cv>` custom elements. If it does, this will be
|
144
|
+
inserted into the correct `<brut-cv-messages>` elements to dynamically create error messages. The element will then fire a
|
145
|
+
`brut:submitinvalid` event you can catch and handle to do something custom.
|
146
|
+
|
147
|
+
If the submission times out or fails in some other way, Brut will submit the form the old-fashioned way.
|
148
|
+
|
149
|
+
### Client-Side, Accessible Tabs
|
150
|
+
|
151
|
+
Many UIs involve a set of tabs that switch between different views. While HTML has no built-in support for this, Brut's `<brut-tabs>`
|
152
|
+
custom element can captialize on the various ARIA roles required to design a tabbed interface and provide all the JavaScript behavior
|
153
|
+
necessary. See that element's documentation for an extended example.
|
154
|
+
|
155
|
+
## Building Your Own Custom Elements
|
156
|
+
|
157
|
+
Because custom elements are part of the web platform, Brut encourages you to use them to add client-side behavior. As a
|
158
|
+
demonstration of this working, there is an `Example` element set up when you created your app. Assuming your app's prefix was `cc`
|
159
|
+
when you created it, the `Example` element works like so:
|
160
|
+
|
161
|
+
<cc-example transform="upper">
|
162
|
+
Here is some text
|
163
|
+
</cc-example>
|
164
|
+
|
165
|
+
When the browser renders this—assuming JavaScript is enabled—it will render the following:
|
166
|
+
|
167
|
+
<cc-example transform="upper">
|
168
|
+
HERE IS SOME TEXT
|
169
|
+
</cc-example>
|
170
|
+
|
171
|
+
You wouldn't want to do this, but this simple element demonstrates both how to make your own and that custom elements in your app are
|
172
|
+
properly configured.
|
173
|
+
|
174
|
+
This element is very close to a vanilla custom element, but it extends `BaseCustomElement`, which is provided by BrutJS, which includes
|
175
|
+
z few quality-of-life improvements. Let's walk through the code.
|
176
|
+
|
177
|
+
First, we extends `BaseCustomElement` (which extends `HTMLElement`) and define a static attribute, `tagName` that will be the
|
178
|
+
element's tag name you can use in your HTML:
|
179
|
+
|
180
|
+
import { BaseCustomElement } from "brut-js"
|
181
|
+
|
182
|
+
class Example extends BaseCustomElement {
|
183
|
+
static tagName = "cc-example"
|
184
|
+
|
185
|
+
Next, we'll define the attributes of our element using `observedAttributes`, which is part of the custom element spec (and not
|
186
|
+
specific to BrutJS):
|
187
|
+
|
188
|
+
static observedAttributes = [
|
189
|
+
"transform",
|
190
|
+
"show-warnings",
|
191
|
+
]
|
192
|
+
|
193
|
+
The `show-warnings` attribute, if placed on the element's HTML, configures `this.logger` from `BaseCustomElement` to allow output of debug messages. This allows you to easily debug your element's behavior in development, but remove them from production. We'll see that in a bit.
|
194
|
+
|
195
|
+
Next, we'll set a private attribute to hold a default value for the `transform` HTML attribute. It can be named anything:
|
196
|
+
|
197
|
+
#transform = "upper"
|
198
|
+
|
199
|
+
Now, we want to know when `transform` changes. Normally, you'd implement `attributeChangedCallback` and check its `name` parameter.
|
200
|
+
`BaseCustomElement` allows you to do this more directly by creating a `xxxChangedCallback` method for each attribute in
|
201
|
+
`observedAttributes` that you want to know about. For `transform`, that means implementing `transformChangedCallback`:
|
202
|
+
|
203
|
+
transformChangedCallback({newValue}) {
|
204
|
+
this.#transform = newValue
|
205
|
+
}
|
206
|
+
|
207
|
+
Next, we implement the bulk of the element's behavior. Because there are many lifecycle events that may require modifying the
|
208
|
+
element, `BaseCustomElement` consolidates all of those events and calls the method `update()`. `update()` should be idempotent
|
209
|
+
and should examine the element's state (as well as the document's, if necessary) and update the element however it makes sense.
|
210
|
+
|
211
|
+
For this example, we want to grab the content, examine the value for `transform` and change the content:
|
212
|
+
|
213
|
+
update() {
|
214
|
+
const content = this.textContent
|
215
|
+
if (this.#transform == "upper") {
|
216
|
+
this.textContent = content.toLocaleUpperCase()
|
217
|
+
}
|
218
|
+
else if (this.#transform == "lower") {
|
219
|
+
this.textContent = content.toLocaleLowerCase()
|
220
|
+
}
|
221
|
+
else {
|
222
|
+
this.logger.info("We only support upper or lower, but got %s",this.#transform)
|
223
|
+
}
|
224
|
+
}
|
225
|
+
|
226
|
+
Notice the last line where we call `this.logger.info`. If `show-warnings` is omitted from the HTML, this message isn't shown anywhere. If `show-warnings` *is* present, this message will show up in the console. This can be useful for understanding why your element isn't working.
|
227
|
+
|
228
|
+
Lastly, we export the class:
|
229
|
+
|
230
|
+
}
|
231
|
+
export default Example
|
232
|
+
|
233
|
+
By virtue of having extended `BaseCustomElement`, the static `define()` method will set it up as a custom element with the browser.
|
234
|
+
|
235
|
+
## Testing Custom Elements
|
236
|
+
|
237
|
+
Sometimes, system tests are sufficient to ensure your custom element code is working. If not, Brut (via BrutJS) provides a way to
|
238
|
+
test your element in isolation.
|
239
|
+
|
240
|
+
These tests use JSDom which, while not perfect, allows the tests to run reasonably quickly. Each test begins with `withHTML` to
|
241
|
+
define the markup that is being tested. This is followed by `test` which accepts a function that performs the test.
|
242
|
+
|
243
|
+
Rather than have a DSL to provide access to your element's state, you can use the browser's API (as provided by JSDom) to check
|
244
|
+
whatever it is you need to check:
|
245
|
+
|
246
|
+
import { withHTML } from "./SpecHelper.js"
|
247
|
+
|
248
|
+
describe("<cc-example>", () => {
|
249
|
+
withHTML(`
|
250
|
+
<cc-example>This is some Text</cc-example>
|
251
|
+
`).test("upper case by default", ({document,assert}) => {
|
252
|
+
const element = document.querySelector("cc-example")
|
253
|
+
assert.equal(element.textContent,"THIS IS SOME TEXT")
|
254
|
+
})
|
255
|
+
withHTML(`
|
256
|
+
<cc-example transform="lower">This is some Text</cc-example>
|
257
|
+
`).test("lower case when asked", ({document,assert}) => {
|
258
|
+
const element = document.querySelector("cc-example")
|
259
|
+
assert.equal(element.textContent,"this is some text")
|
260
|
+
})
|
261
|
+
})
|
262
|
+
|
263
|
+
Note that `test`'s second argument—the function that performs the test—is called with objects you can use to perform the test. In this
|
264
|
+
case, the `document` is passed in as-is the `assert` method.
|
265
|
+
|
@@ -0,0 +1,183 @@
|
|
1
|
+
# Keyword Injection
|
2
|
+
|
3
|
+
Brut is desiged around classes and objects, as compared to modules and DSLs. Almost everything you do when creating your app is to
|
4
|
+
create a class that has an initializer and implements one or more methods. But, these initalizers and methods often need information
|
5
|
+
from the request that Brut is managing.
|
6
|
+
|
7
|
+
In a basic Rack or Sinatra app, you would access stuff like query parameters or the session by using Rack's API. This can be tedious
|
8
|
+
and error-prone. Brut will inject certain values into your class based on the keyword arguments of the initializer or method.
|
9
|
+
|
10
|
+
For example, a {file:doc-src/pages.md Page} requires you to implement an initializer. That initializer's keyword arguments define what
|
11
|
+
information is needed. Brut provides that information when it creates the object. This is a form of dependency injection and it can simplify your code if used effectively.
|
12
|
+
|
13
|
+
Consider this route:
|
14
|
+
|
15
|
+
page "/widgets/:id"
|
16
|
+
|
17
|
+
Brut will expect to find `WidgetsByIdPage`. Your initializer can declare `id:` as a keyword arg and this will be passed when the
|
18
|
+
class is created:
|
19
|
+
|
20
|
+
class WidgetsByIdPage < AppPage
|
21
|
+
def initialize(id:)
|
22
|
+
@widget = DB::Widget.find(id)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
There are many more values that can made available. For example, suppose you accept the query string parameter "compact" that
|
27
|
+
controls some rendering of the page. To access it, declare it as a keyword arg (being sure to set a default value since it may not be available):
|
28
|
+
|
29
|
+
class WidgetsByIdPage < AppPage
|
30
|
+
def initialize(id:, compact: false)
|
31
|
+
@widget = DB::Widget.find(id)
|
32
|
+
@compact = compact
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
## Standard Injectible Information
|
37
|
+
|
38
|
+
In any request, the following information is available to be injected:
|
39
|
+
|
40
|
+
* `session:` - An instance of your app's {Brut::FrontEnd::Session} subclass for the current visitor's session.
|
41
|
+
* `flash:` - An instance of your app's {Brut::FrontEnd::Flash} subclass.
|
42
|
+
* `xhr:` - true if this was an Ajax request.
|
43
|
+
* `body:` - the body submitted, if any.
|
44
|
+
* `csrf_token:` - The current CSRF token.
|
45
|
+
* `clock:` - A {Clock} to be used to access the current time in the visitor's time zone.
|
46
|
+
* `http_*` - any parameter that starts with `http_` is assumed to be for an HTTP header. For example, `http_accept_language` would be
|
47
|
+
given the value for the "Accept-Language" header. See the section on HTTP headers below.
|
48
|
+
* `env:` - The Rack env. This is discouraged, but available if you can't get what you want directly
|
49
|
+
|
50
|
+
Depending on the context, other information is available:
|
51
|
+
|
52
|
+
* `form:` - If a form was submitted, this is the {Brut::FrontEnd::Form} subclass containing the data. See {file:doc-src/forms.md Forms}.
|
53
|
+
* Any query string paramter - Remember, these should have default values or Brut will raise an error if they are not provided.
|
54
|
+
* Any route parameter - These should not have default values, since they are required for Brut to match the route.
|
55
|
+
|
56
|
+
A {Brut::FrontEnd::RouteHook} is slightly different. Only the following data is available to be injected:
|
57
|
+
|
58
|
+
* `:request_context` - The current request context, thought it may be `nil` depending on when the hook runs
|
59
|
+
* `session:` - An instance of your app's {Brut::FrontEnd::Session} subclass for the current visitor's session.
|
60
|
+
* `:request` - The Rack request
|
61
|
+
* `:response` - The Rack response
|
62
|
+
* `env:` - The Rack env.
|
63
|
+
|
64
|
+
### HTTP Headers
|
65
|
+
|
66
|
+
Since any header can be sent with a request, Brut allows you to access them, including non-standard ones. Rack (which is based on CGI), provides access to all HTTP headers in the `env` by taking the header name, replacing dashes ("-") with underscores ("\_"), and prepending `http_` to the name, then uppercasing it. Thus, "User-Agent" becomes `HTTP_USER_AGENT`.
|
67
|
+
|
68
|
+
Because Ruby parameters and variables must start with a lower-case letter, Brut uses the lowercased version of the Rack/CGI variable.
|
69
|
+
Thus, to receive the "User-Agent", you would declare the keyword parameter `http_user_agent`.
|
70
|
+
|
71
|
+
Further, because headers come from the client and may not be under your control, the value that is actually injected depends on a few
|
72
|
+
things:
|
73
|
+
|
74
|
+
* If your keyword arg is required, i.e. there is no default value:
|
75
|
+
- If the header was not provided, `nil` is injected.
|
76
|
+
- If the header *was* provided, it's value is injected, even if it's the empty string.
|
77
|
+
* If your keyword arg is optional, i.e. it has a default value
|
78
|
+
- If the header was not provided, no value is injected, and your code will receive the default value.
|
79
|
+
- If the header *was* provided, it's value is injected, even if it's the empty string.
|
80
|
+
|
81
|
+
### Ordering and Disambiguation
|
82
|
+
|
83
|
+
You are discouraged from using builtin keys for your own data or request parameters. For example, you should not have a query string
|
84
|
+
parameter named `env` as this conflicts with the builtin `env` that Brut will inject.
|
85
|
+
|
86
|
+
Since you can inject your own data (see below), you are free to corrupt the request context. Please don't do this. Brut may actively
|
87
|
+
prevent this in the future.
|
88
|
+
|
89
|
+
You can also use the request context to put your own data that can be injected.
|
90
|
+
|
91
|
+
## Injecting Custom Data
|
92
|
+
|
93
|
+
The general lifecycle of a request is that any before hook is run, then the page or action is triggered, then after actions. Thus, to
|
94
|
+
inject your own data, such as the currently authenticated visitor, you would use a before hook:
|
95
|
+
|
96
|
+
class AppSession < Brut::FrontEnd::Session
|
97
|
+
def logged_in?
|
98
|
+
!!self.authenticated_account
|
99
|
+
end
|
100
|
+
def authenticated_account
|
101
|
+
# look up the account data model
|
102
|
+
# based on e.g. self[:account_id]
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class AuthBeforeHook < Brut::FrontEnd::RouteHook
|
107
|
+
def before(request_context:,session:,request:,env:)
|
108
|
+
if session.logged_in?
|
109
|
+
request_context[:authenticated_account] = session.authenticated_account
|
110
|
+
end
|
111
|
+
continue
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
Once you do this, you can use `authenticated_account:` as a keyword argument to any page, handler, or global component:
|
116
|
+
|
117
|
+
class DashboardPage < AppPage
|
118
|
+
def initialize(authenticated_account:)
|
119
|
+
@widgets = authenticated_account.widgets # e.g.
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
While this won't handle authorization for you, you can be sure that when `DashboardPage` is used, there is an `authenticated_account`
|
124
|
+
available.
|
125
|
+
|
126
|
+
## `nil` and Empty Strings
|
127
|
+
|
128
|
+
When a keyword argument has no default value, Brut will require that value to exist and be available for injection. If the keyword is
|
129
|
+
not one of the canned always-available values, it will look in the request context, then in the query string.
|
130
|
+
|
131
|
+
If the request has the keyword as a key, *it will inject whatever value it finds, including `nil`*. In general, you should avoid
|
132
|
+
injecting `nil` when you actually intend to not have a value.
|
133
|
+
|
134
|
+
For example, the `AuthBeforeHook` above, you could implement it like so:
|
135
|
+
|
136
|
+
request_context[:authenticated_account] = session.authenticated_account
|
137
|
+
|
138
|
+
The problem is that if the visitor is not logged in, the `:authenticated_account` *will* have a value, and that value will be `nil`.
|
139
|
+
This is almost certainly not what you want.
|
140
|
+
|
141
|
+
For query string parameters, the HTTP spec says that they are strings. Thus, if a query string parameter is present in ther request
|
142
|
+
URL, it will *always* have a value and *never* be `nil`. If the paramter doesn't have a value after the `=` (e.g. for `foo` in `?foo=&bar=quux`), the value will be the empty string.
|
143
|
+
|
144
|
+
This means you must write code to explicitly handle the cases you care about.
|
145
|
+
|
146
|
+
## When Values Aren't Available
|
147
|
+
|
148
|
+
When a value is not available for injection, and the keyword doesn't provide a default, Brut will raise an error. This is because
|
149
|
+
such a situation represents a design error.
|
150
|
+
|
151
|
+
For example, the `DashboardPage` above requires an `authenticated_account`. Your app should never route a logged-out visitor to that
|
152
|
+
page. This allows the `DashboardPage` to avoid having to check for `nil` and figure out what to do.
|
153
|
+
|
154
|
+
This is most relevant for query string parameters, since they can be easily manipulated by the visitor in their browser. Query string
|
155
|
+
parameters should always have a default value, even if it's `nil`.
|
156
|
+
|
157
|
+
*Path* parameters (like `:id` in `WidgetsByIdPage`) should *never* have a default value as their absence means a different URL was
|
158
|
+
requested. For example, `/widgets` would trigger a `WidgetsPage`. *Only* if the `:id` path parameter is present would the
|
159
|
+
`WidgetsByIdPage` be triggered, so it's safe to omit the default value for `id:` (and pointless to include one).
|
160
|
+
|
161
|
+
See {file:docs-src/route-hooks.md}
|
162
|
+
|
163
|
+
## Design For Injection
|
164
|
+
|
165
|
+
Consider a method like so:
|
166
|
+
|
167
|
+
def create_widget(name:, organization: nil, quantity: 10)
|
168
|
+
|
169
|
+
Outside of Brut, the way to interpret this arguments is as follows:
|
170
|
+
|
171
|
+
* `name` is required
|
172
|
+
* `organization` is optional
|
173
|
+
* `quantity` has a default value of 10 if not provided
|
174
|
+
|
175
|
+
Any method or intializer that will be keyword-injected should be designed with this in mind. Thus, the following guidelines will be
|
176
|
+
helpful in managing your app:
|
177
|
+
|
178
|
+
* **Choose arguments based on the needs of the class:**
|
179
|
+
- If a value is optional, default it to either `nil` or a symbol that indicates what happens when the value is omitted
|
180
|
+
- If an optional value has a default, use that (this should be rare for pages, handlers, components, and hooks)
|
181
|
+
- Otherwise, do not provide a default for the keyword
|
182
|
+
* **Do not inject `nil` into the request context.** When your code requires a value for a keyword, you want to rely on that value being non-nil. Thus, avoid injecting `nil` into the request context. Brut will allow it as a sort-of escape hatch, but you should design your app to avoid it
|
183
|
+
* **Be careful injecting global data.** The request context instance is per request, but you could certainly put global data into it. For example, you may put an initialized API client into the request context as a convieniece. **Be careful** because your app is multi-threaded. Any object that is not scoped to the request must be thread-safe.
|