From fff4419f6931392e2b29bffd63aa7fdd8b4156a5 Mon Sep 17 00:00:00 2001 From: Jared White Date: Sat, 11 Jan 2025 22:54:39 -0800 Subject: [PATCH] Refactor Roda plugin setup, add Roda "distribution" docs (WIP) (#969) * Move some Roda plugins out of server default and into SSR plugin - Add new documentation on how Roda is configured - BREAKING CHANGE: the `flash` plugin is no longer loaded by the file-based routes plugin by default, it's now configured with `sessions: true` in the ssr plugin init. Flash required sessions to be set up anyway, which used to be manual but is now provided by this new config. * Flesh out the Roda reference guide * A few additional tweaks to Roda docs * remove unnecessary Rubocop code in docs example --- .../lib/bridgetown-core/rack/boot.rb | 8 --- .../lib/bridgetown-core/utils/initializers.rb | 4 +- .../lib/roda/plugins/bridgetown_server.rb | 5 -- .../lib/roda/plugins/bridgetown_ssr.rb | 22 +++++- bridgetown-core/lib/roda/plugins/flashier.rb | 57 +++++++++++++++ .../lib/site_template/config/initializers.rb | 2 + .../test/ssr/config/local_ssr_init.rb | 1 + .../test/ssr/server/routes/flashy.rb | 14 ++++ bridgetown-core/test/test_ssr.rb | 13 +++- .../lib/bridgetown-routes/flash_additions.rb | 39 ---------- .../lib/bridgetown-routes/view_helpers.rb | 18 ++++- .../lib/roda/plugins/bridgetown_routes.rb | 16 ----- .../src/_docs/configuration/initializers.md | 10 ++- bridgetown-website/src/_docs/roda.md | 71 +++++++++++++++++++ bridgetown-website/src/_docs/routes.md | 16 +++-- 15 files changed, 208 insertions(+), 88 deletions(-) create mode 100644 bridgetown-core/lib/roda/plugins/flashier.rb create mode 100644 bridgetown-core/test/ssr/server/routes/flashy.rb delete mode 100644 bridgetown-routes/lib/bridgetown-routes/flash_additions.rb create mode 100644 bridgetown-website/src/_docs/roda.md diff --git a/bridgetown-core/lib/bridgetown-core/rack/boot.rb b/bridgetown-core/lib/bridgetown-core/rack/boot.rb index e166843ab..8b272883e 100644 --- a/bridgetown-core/lib/bridgetown-core/rack/boot.rb +++ b/bridgetown-core/lib/bridgetown-core/rack/boot.rb @@ -27,14 +27,6 @@ def self.boot(*) LoaderHooks.autoload_server_folder( File.join(Bridgetown::Current.preloaded_configuration.root_dir, "server") ) - rescue Roda::RodaError => e - if e.message.include?("sessions plugin :secret option") - raise Bridgetown::Errors::InvalidConfigurationError, - "The Roda sessions plugin can't find a valid secret. Run `bin/bridgetown secret' " \ - "and put the key in a ENV var you can use to configure the session in the Roda app" - end - - raise e end end end diff --git a/bridgetown-core/lib/bridgetown-core/utils/initializers.rb b/bridgetown-core/lib/bridgetown-core/utils/initializers.rb index 51efea836..73af37d8a 100644 --- a/bridgetown-core/lib/bridgetown-core/utils/initializers.rb +++ b/bridgetown-core/lib/bridgetown-core/utils/initializers.rb @@ -4,9 +4,9 @@ Bridgetown.load_dotenv root: config.root_dir end -Bridgetown.initializer :ssr do |config, setup: nil| +Bridgetown.initializer :ssr do |config, setup: nil, **options| config.roda do |app| - app.plugin(:bridgetown_ssr, &setup) + app.plugin(:bridgetown_ssr, options, &setup) end end diff --git a/bridgetown-core/lib/roda/plugins/bridgetown_server.rb b/bridgetown-core/lib/roda/plugins/bridgetown_server.rb index a2528077d..7106175d6 100644 --- a/bridgetown-core/lib/roda/plugins/bridgetown_server.rb +++ b/bridgetown-core/lib/roda/plugins/bridgetown_server.rb @@ -13,14 +13,9 @@ def self.load_dependencies(app) # rubocop:disable Metrics app.extend ClassMethods # we need to do this here because Roda hasn't done it yet app.plugin :initializers - app.plugin :method_override - app.plugin :all_verbs - app.plugin :hooks app.plugin :common_logger, Bridgetown::Rack::Logger.new($stdout), method: :info app.plugin :json app.plugin :json_parser - app.plugin :indifferent_params - app.plugin :cookies, path: "/" app.plugin :ssg, root: Bridgetown::Current.preloaded_configuration.destination app.plugin :not_found do output_folder = Bridgetown::Current.preloaded_configuration.destination diff --git a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb index 3614b3f9c..e642647d9 100644 --- a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb +++ b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb @@ -13,13 +13,31 @@ def bridgetown_site alias_method :site, :bridgetown_site end - def self.load_dependencies(app) - app.plugin :custom_block_results + def self.load_dependencies(app, opts = { sessions: false }) + app.plugin :all_verbs + app.plugin :cookies, path: "/" + app.plugin :indifferent_params + app.plugin :method_override + app.plugin :route_csrf # This lets us return callable objects directly in Roda response blocks + app.plugin :custom_block_results app.handle_block_result(Bridgetown::RodaCallable) do |callable| request.send :block_result_body, callable.(self) end + + return unless opts[:sessions] + + secret_key = ENV.fetch("RODA_SECRET_KEY", nil) + unless secret_key + raise Bridgetown::Errors::InvalidConfigurationError, + "The Roda sessions plugin can't find a valid secret. Run " \ + "`bin/bridgetown secret' and put the key in your ENV as the " \ + "RODA_SECRET_KEY variable" + end + + app.plugin :sessions, secret: secret_key + app.plugin :flashier end def self.configure(app, _opts = {}, &) diff --git a/bridgetown-core/lib/roda/plugins/flashier.rb b/bridgetown-core/lib/roda/plugins/flashier.rb new file mode 100644 index 000000000..9d6b2cdff --- /dev/null +++ b/bridgetown-core/lib/roda/plugins/flashier.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class Roda + module RodaPlugins + module Flashier + module FlashHashAdditions + def info + self["info"] + end + + def info=(val) + self["info"] = val + end + + def alert + self["alert"] + end + + def alert=(val) + self["alert"] = val + end + end + + module FlashHashIndifferent + def []=(key, val) + @next[key.to_s] = val + end + end + + module FlashNowHashIndifferent + def []=(key, val) + super(key.to_s, val) + end + + def [](key) + super(key.to_s) + end + end + + def self.load_dependencies(app) + require "roda/plugins/flash" + + Roda::RodaPlugins::Flash::FlashHash.include FlashHashAdditions, FlashHashIndifferent + Roda::RodaPlugins::Flash::FlashHash.class_eval do + def initialize(hash = {}) + super(hash || {}) + now.singleton_class.include FlashHashAdditions, FlashNowHashIndifferent + @next = {} + end + end + app.plugin :flash + end + end + + register_plugin :flashier, Flashier + end +end diff --git a/bridgetown-core/lib/site_template/config/initializers.rb b/bridgetown-core/lib/site_template/config/initializers.rb index e346eabb0..23104f153 100644 --- a/bridgetown-core/lib/site_template/config/initializers.rb +++ b/bridgetown-core/lib/site_template/config/initializers.rb @@ -68,6 +68,8 @@ # # init :ssr # + # Add `sessions: true` if you need to use session data, flash, etc. + # # Uncomment to use file-based dynamic template routing via Roda (make sure you # uncomment the gem dependency in your `Gemfile` as well): diff --git a/bridgetown-core/test/ssr/config/local_ssr_init.rb b/bridgetown-core/test/ssr/config/local_ssr_init.rb index 8b55e71a0..d6e87adb7 100644 --- a/bridgetown-core/test/ssr/config/local_ssr_init.rb +++ b/bridgetown-core/test/ssr/config/local_ssr_init.rb @@ -2,6 +2,7 @@ Bridgetown.initializer :local_ssr_init do |config| config.init :ssr do + sessions true setup -> site do # rubocop:disable Layout/SpaceInLambdaLiteral, Style/StabbyLambdaParentheses site.data.iterations ||= 0 site.data.iterations += 1 diff --git a/bridgetown-core/test/ssr/server/routes/flashy.rb b/bridgetown-core/test/ssr/server/routes/flashy.rb new file mode 100644 index 000000000..4faa9e250 --- /dev/null +++ b/bridgetown-core/test/ssr/server/routes/flashy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class Routes::Flashy < Bridgetown::Rack::Routes + route do |r| + # route: POST /flashy/:name + r.post "flashy", String do |name| + flash.info = "Save this value: #{name}" + end + + r.get "flashy" do + { saved: flash.info } + end + end +end diff --git a/bridgetown-core/test/test_ssr.rb b/bridgetown-core/test/test_ssr.rb index f20ed197c..9498ffbf9 100644 --- a/bridgetown-core/test/test_ssr.rb +++ b/bridgetown-core/test/test_ssr.rb @@ -8,7 +8,10 @@ class TestSSR < BridgetownUnitTest include Rack::Test::Methods def app - @@ssr_app ||= Rack::Builder.parse_file(File.expand_path("ssr/config.ru", __dir__)) # rubocop:disable Style/ClassVars + @@ssr_app ||= begin # rubocop:disable Style/ClassVars + ENV["RODA_SECRET_KEY"] = SecureRandom.hex(64) + Rack::Builder.parse_file(File.expand_path("ssr/config.ru", __dir__)) + end end def site @@ -94,6 +97,14 @@ def site assert_equal "WOW true", last_response.body end + should "return flash value" do + post "/flashy/abc12356" + + get "/flashy" + + assert_equal({ "saved" => "Save this value: abc12356" }, JSON.parse(last_response.body)) + end + should "return rendered view" do get "/render_view/Page_Me" diff --git a/bridgetown-routes/lib/bridgetown-routes/flash_additions.rb b/bridgetown-routes/lib/bridgetown-routes/flash_additions.rb deleted file mode 100644 index b88386177..000000000 --- a/bridgetown-routes/lib/bridgetown-routes/flash_additions.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -module Bridgetown - module Routes - module FlashHashAdditions - def info - self["info"] - end - - def info=(val) - self["info"] = val - end - - def alert - self["alert"] - end - - def alert=(val) - self["alert"] = val - end - end - - module FlashHashIndifferent - def []=(key, val) - @next[key.to_s] = val - end - end - - module FlashNowHashIndifferent - def []=(key, val) - super(key.to_s, val) - end - - def [](key) - super(key.to_s) - end - end - end -end diff --git a/bridgetown-routes/lib/bridgetown-routes/view_helpers.rb b/bridgetown-routes/lib/bridgetown-routes/view_helpers.rb index 435cadd82..5610ea2bc 100644 --- a/bridgetown-routes/lib/bridgetown-routes/view_helpers.rb +++ b/bridgetown-routes/lib/bridgetown-routes/view_helpers.rb @@ -1,14 +1,26 @@ # frozen_string_literal: true -require_relative "flash_additions" - module Bridgetown module Routes module ViewHelpers # This flash is only used as a stub for views in case there's no Roda flash # available in the rendering context class Flash < Hash - include Bridgetown::Routes::FlashHashAdditions + def info + self["info"] + end + + def info=(val) + self["info"] = val + end + + def alert + self["alert"] + end + + def alert=(val) + self["alert"] = val + end def now self diff --git a/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb b/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb index 161440163..b1bc737b4 100644 --- a/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb +++ b/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb @@ -1,27 +1,11 @@ # frozen_string_literal: true -require "roda/plugins/flash" -require_relative "../../bridgetown-routes/flash_additions" - -Roda::RodaPlugins::Flash::FlashHash.include Bridgetown::Routes::FlashHashAdditions, - Bridgetown::Routes::FlashHashIndifferent -Roda::RodaPlugins::Flash::FlashHash.class_eval do - def initialize(hash = {}) - super(hash || {}) - now.singleton_class.include Bridgetown::Routes::FlashHashAdditions, - Bridgetown::Routes::FlashNowHashIndifferent - @next = {} - end -end - class Roda module RodaPlugins module BridgetownRoutes def self.load_dependencies(app) app.plugin :slash_path_empty # now /hello and /hello/ are both matched app.plugin :placeholder_string_matchers - app.plugin :flash - app.plugin :route_csrf end def self.configure(app, _opts = {}) diff --git a/bridgetown-website/src/_docs/configuration/initializers.md b/bridgetown-website/src/_docs/configuration/initializers.md index 39c75d78b..2a00f2c42 100644 --- a/bridgetown-website/src/_docs/configuration/initializers.md +++ b/bridgetown-website/src/_docs/configuration/initializers.md @@ -17,6 +17,7 @@ Bridgetown.configure do |config| config.autoload_paths << "jobs" init :ssr do + sessions true setup -> site do # perform site setup tasks only in the server context end @@ -215,16 +216,12 @@ While it's not strictly required that you place a Roda block inside of an `only ### SSR & Dynamic Routes -The SSR features of Bridgetown, along with its companion file-based routing features, are now configurable via initializers. +The SSR features of Bridgetown, along with its companion file-based routing features, are configurable via initializers. ```rb -init :ssr +init :ssr, sessions: true # the dotenv initializer is also recommended, more on that below # optional: -init :"bridgetown-routes" - -# …or you can just init the routes, which will init :ssr automatically: - init :"bridgetown-routes" ``` @@ -232,6 +229,7 @@ If you want to run some specific site setup code on first boot, or any time ther ```rb init :ssr do + sessions true setup -> site do # access the site object, add data with `site.data`, whatever end diff --git a/bridgetown-website/src/_docs/roda.md b/bridgetown-website/src/_docs/roda.md new file mode 100644 index 000000000..5de4bb8a7 --- /dev/null +++ b/bridgetown-website/src/_docs/roda.md @@ -0,0 +1,71 @@ +--- +title: Roda Reference Guide +order: 255 +top_section: Architecture +category: roda +--- + +Bridgetown comes with what we like to call an "opinionated distribution" of the [Roda web toolkit](https://roda.jeremyevans.net), meaning we've configured a number of plugins right out of the box for enhanced developer experience. + +{%@ Note do %} +For a general overview of how to author server-side code, see [Server-Rendered Routes](/docs/routes). +{% end %} + +This base configuration is itself provided by the `bridgetown_server` plugin. On a fresh install of a Bridgetown site, you'll get a `server/roda_app.rb` file with the following: + +```ruby +class RodaApp < Roda + plugin :bridgetown_server + + route do |r| + r.bridgetown + end +end +``` + +The `r.bridgetown` method call spins up Bridgetown's own routing system which is comprised of subclasses of `Bridgetown::Rack::Routes`and if the `bridgetown-routes` plugin is active, file-based routing (in `src/_routes`) as well. + +The `bridgetown_server` plugin configures the following Roda plugins: + +* [common_logger](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/CommonLogger.html) - connects Roda up with Bridgetown's logger +* [json](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Json.html) - allows arrays or hashes returned from a route block to be converted to JSON output automatically, along with setting `application/json` as the content type for the response +* [json_parser](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/JsonParser.html) - parses incoming JSON data and provides it via `r.POST` and also `r.params` + +Bridgetown also sets up the [not_found](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/NotFound.html), [exception_page](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/ExceptionPage.html), and [error_handler](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/ErrorHandler.html) plugins to deal with errors that may arise when processing a request. + +We also load our custom `ssg` plugin which is loosely based on Roda's `public` plugin and provides serving of static assets using "pretty URLs", aka: + +* `/path/to/page` -> `/path/to/page.html` or `/path/to/page/index.html` +* `/path/to/page/` -> `/path/to/page/index.html` + +If you add `init :ssr` to your [Initializers](/docs/configuration/initializers) config, the `bridgetown_ssr` plugin is loaded which configures these additional plugins: + +* [all_verbs](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/AllVerbs.html) - adds routing methods for additional HTTP verbs like PUT, PATCH, DELETE, etc. +* [cookies](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Cookies.html) - adds response methods for setting or deleting cookies, default path is root (`/`) +* [indifferent_params](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/IndifferentParams.html) - lets you access request params using symbols in addition to strings, and also provides a `params` instance method (no need to use `r.`) +* [route_csrf](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/RouteCsrf.html) - this helps protect against cross-site request forgery in form submissions +* [custom_block_results](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/CustomBlockResults.html) - lets Roda route blocks return arbitrary objects which can be processed with custom handlers. We use this to enable our [RodaCallable](/docs/routes#callable-objects-for-rendering-within-blocks) functionality +* `method_override` - this Bridgetown-supplied plugin looks for the presence of a `_method` form param and will use that to override the incoming HTTP request method. Thus even if a form comes in as POST, if `_method` equals `PUT` the request method will be `PUT`. + +If you pass `sessions: true` to the `ssr` initializer in your config, you'll get these plugins added: + +* [sessions](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Sessions.html) - adds support for cookie-based session storage (aka a small amount of key-val data storage which persists across requests for a single client). You'll need to have `RODA_SECRET_KEY` defined in your environment. To make this easy in local development, you should set up the [Dotenv gem](/docs/configuration/initializers#dotenv). Setting up a secret key is a matter of running `bin/bridgetown secret` and then copying the key to `RODA_SECRET_KEY`. +* [flash](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Flash.html) - provides a `flash` object you can access from both routes and view templates. + +The following `flash` methods are available: + +* `flash.info = "..."` / `flash.info` - set an informational message in a route which can then be read once after a redirect. +* `flash.alert = "..."` / `flash.alert` - set an alert message in a route which can then be read once after a redirect. +* `flash.now` - lets you set and retrieve flash messages (both `.info` and `.alert`) for the current request/response cycle. + +## Bridgetown Route Classes + +If you've come to Bridgetown already familiar with Roda, you may be wondering what `Bridgetown::Rack::Routes` is and how it works. + +Because a traditional Roda application isn't oriented towards an architecture which plays well with Zeitwerk-based reloading in development, we decided to eschew Roda's recommended solutions for creating multiple route files (such as the `hash_branches` plugin) in favor of a class-based solution. + +During the `r.bridgetown` routing process, every loaded `Bridgetown::Rack::Routes` class is evaluated in turn (sorted by [priority](/docs/routes#priority-flag)) until a route handler has been found (or in lieu of that, a generic 404 response is returned). Route handlers are provided via the `route` class method, and the block you provide is evaluated in an instance of that class (not the Roda application itself). + +{%@ Note do %} +You can still access methods of the Roda application from within a route block because `Bridgetown::Rack::Routes` defines `method_missing` and delegates calls accordingly. So for example if you were to call `flash` in your route block, that call would be passed along to the Roda application. However, if for some reason you were to write `def flash` to create an instance method, you'd no longer have access to Roda's `flash`. So it's recommended that if you do write custom instance methods, you avoid using names which interfere with typical Roda app methods. +{% end %} diff --git a/bridgetown-website/src/_docs/routes.md b/bridgetown-website/src/_docs/routes.md index e16d47588..b74b3daab 100644 --- a/bridgetown-website/src/_docs/routes.md +++ b/bridgetown-website/src/_docs/routes.md @@ -12,11 +12,7 @@ Bridgetown lets you create your own Roda-based API routes in the `server/routes` However, to take full advantage of all the Bridgetown has to offer, we recommend you load up our SSR and Dynamic Routes plugins. Add to your configuration in `config/initializers.rb`: ```rb -init :ssr -init :"bridgetown-routes" - -# …or you can init the routes, which will init :ssr automatically: - +init :ssr # add `sessions: true` if you want to save session data, use flash, etc. init :"bridgetown-routes" ``` @@ -148,7 +144,7 @@ end class Views::Product < Bridgetown::Component include Bridgetown::Viewable - def initialize(product:) # rubocop:disable Lint/MissingSuper + def initialize(product:) @product = product data.title = @product.title @@ -388,6 +384,14 @@ end Flash, session, cookies, CSRF, etc. --> +## Building Web Applications With Roda + +Besides the features that Bridgetown uniquely provides, described thus far, many of the features you'll use in the typical course of building out an application are directly supplied by Roda. + +Bridgetown comes with what we like to call an "opinionated distribution" of Roda. Unlike a first install of Roda where no plugins have yet to be configured, Bridgetown configures a number of plugins right out of the box for enhanced developer experience. + +[Read our Roda reference guide](/docs/roda) for more on this base configuration, as well as some of the helpers and utilities made available. +