Skip to content

Commit 34f2456

Browse files
committed
Replace magic getter/setter for phlex options with explicit set_ methods
1 parent edea75f commit 34f2456

File tree

5 files changed

+141
-75
lines changed

5 files changed

+141
-75
lines changed

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ gem install roda-phlex
3030
* `:layout_opts` (`Object`): Options that are passed to the layout
3131
class when it is instantiated. These options can be used to customize
3232
the behavior of the layout. Usually, this is a `Hash`.
33+
To avoid external changes effecting subsequent requests, you should `.freeze` this
34+
and all nested objects.
3335
* `:layout_handler` (`#call`): A custom handler for creating layout
3436
instances. This proc receives three arguments: the layout class, the
3537
layout options, and the object to be rendered. By default, it runs
@@ -142,16 +144,17 @@ end
142144

143145
```ruby
144146
# Define a default layout and layout options for the whole application
145-
plugin :phlex, layout: MyLayout, layout_opts: { title: +"My App" }
147+
plugin :phlex, layout: MyLayout, layout_opts: { title: "My App".freeze }.freeze
148+
146149
route do |r|
147150
r.on "posts" do
148151
# redefine the layout and layout options for this route tree
149-
phlex_layout MyPostLayout
150-
phlex_layout_opts[:title] << " - Posts"
152+
set_phlex_layout MyPostLayout
153+
set_phlex_layout_opts phlex_layout_opts.merge(title: "#{phlex_layout_opts[:title]} - Posts")
151154

152155
r.get 'new' do
153156
# Redefine the layout and layout options for this route
154-
phlex_layout_opts[:title] = "Create new post"
157+
phlex_layout_opts[:title] << " - Create new post"
155158
phlex MyView.new
156159
end
157160
end

lib/roda/plugins/phlex.rb

Lines changed: 66 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,6 @@ module RodaPlugins
2626
# (like "ApplicationView") to avoid polluting the global namespace.
2727
# - `:delegate_name`: The name of the method that delegates to the Roda app. Defaults to `"app"`.
2828
module Phlex
29-
Undefined = Object.new
30-
private_constant :Undefined
31-
3229
Error = Class.new(StandardError)
3330

3431
# Custom TypeError class for Phlex errors.
@@ -101,90 +98,98 @@ def #{delegate}(...)
10198
end
10299

103100
module InstanceMethods
104-
# Retrieves or sets the layout.
105-
# When no argument is provided, it returns the current layout.
106-
# Use +nil+ or +false+ to disable layout.
107-
#
108-
# @param layout [Class, nil, false] The layout (a +Phlex::SGML+ class) to be set.
101+
# Retrieves the layout class.
109102
# @return [Class, nil] The current layout (a +Phlex::SGML+ class) or nil if not set.
110-
def phlex_layout(layout = Undefined)
111-
case layout
112-
when Undefined
113-
opts.dig(:phlex, :layout)
114-
when nil, false
115-
opts[:phlex].delete(:layout)
116-
else
117-
if layout <= ::Phlex::SGML
118-
opts[:phlex][:layout] = layout
119-
else
120-
raise TypeError.new(layout)
121-
end
122-
end
103+
def phlex_layout
104+
return @_phlex_layout if defined?(@_phlex_layout)
105+
@_phlex_layout = opts.dig(:phlex, :layout)
123106
end
124107

125-
# Retrieves or sets the layout options.
126-
# When no argument is provided, it returns the current layout options.
127-
# Use +nil+ to delete layout options.
108+
# Sets the layout class.
128109
#
129-
# @note The {DEFAULT_LAYOUT_HANDLER} expects +layout_opts+ to be a +Hash+.
130-
# @param layout_opts [Object, nil] The layout options to be set, usually a +Hash+.
131-
# @return [Object, nil] The current layout options or nil if not set.
132-
def phlex_layout_opts(layout_opts = Undefined)
133-
case layout_opts
134-
when Undefined
135-
opts.dig(:phlex, :layout_opts)
136-
when nil
137-
opts[:phlex].delete(:layout_opts)
110+
# @param layout [Class, nil] The layout class to be set.
111+
# @return [Class, nil] The layout class that was set.
112+
def set_phlex_layout(layout)
113+
if !layout || layout <= ::Phlex::SGML
114+
@_phlex_layout = layout
138115
else
139-
opts[:phlex][:layout_opts] = layout_opts
116+
raise TypeError.new(layout)
140117
end
141118
end
142119

143-
# Retrieves or sets the layout handler.
144-
# When no argument is provided, it returns the current layout handler.
145-
# Use +nil+ or +:default: to reset the layout handler to the {DEFAULT_LAYOUT_HANDLER}.
146-
#
147-
# @param handler [#call, nil, :default] The layout handler to be set.
120+
# Retrieves the layout options hash.
121+
# @return [Object, nil] The current layout options or nil if not set.
122+
# @note Layout options set via the plugin configuration will get `dep`ed
123+
# for the current request. Be aware that this is a shallow copy and
124+
# changes to nested objects will affect the original object, that is
125+
# all subsequent requests. This is *unsafe* and should be avoided:
126+
# ```ruby
127+
# plugin :phlex, layout_opts: {key: {nested: "value"}}
128+
# # ...
129+
# # UNSAFE: Changes to phlex_layout_opts[:key] will affect the plugin config.
130+
# phlex_layout_opts[:key][:nested] = "other value"
131+
# ```
132+
def phlex_layout_opts
133+
return @_phlex_layout_opts if defined?(@_phlex_layout_opts)
134+
@_phlex_layout_opts = opts.dig(:phlex, :layout_opts).dup
135+
end
136+
137+
# Sets the layout options.
138+
# @param layout_opts [Object, nil] The layout options to be set.
139+
# @return [Object, nil] The layout options that were set.
140+
# @note The {DEFAULT_LAYOUT_HANDLER} expects +layout_opts+ to be a +Hash+.
141+
def set_phlex_layout_opts(layout_opts)
142+
@_phlex_layout_opts = layout_opts
143+
end
144+
145+
# Retrieves the layout handler.
148146
# @return [#call] The current layout handler.
149-
def phlex_layout_handler(handler = Undefined)
150-
case handler
151-
when Undefined
152-
opts.dig(:phlex, :layout_handler)
147+
def phlex_layout_handler
148+
return @_phlex_layout_handler if defined?(@_phlex_layout_handler)
149+
@_phlex_layout_handler = opts.dig(:phlex, :layout_handler)
150+
end
151+
152+
# Sets the layout handler.
153+
# Use +nil+ or +:default: to reset the layout handler to the {DEFAULT_LAYOUT_HANDLER}.
154+
def set_phlex_layout_handler(handler)
155+
@_phlex_layout_handler = case handler
153156
when nil, :default
154-
opts[:phlex][:layout_handler] = DEFAULT_LAYOUT_HANDLER
157+
DEFAULT_LAYOUT_HANDLER
155158
else
156-
opts[:phlex][:layout_handler] = handler
159+
handler
157160
end
158161
end
159162

160-
# Retrieves or sets the Phlex context.
161-
# When no argument is provided, it returns the current Phlex context.
162-
#
163+
# Retrieves the Phlex context.
164+
# @return [Hash, nil] The current Phlex context.
165+
def phlex_context
166+
return @_phlex_context if defined?(@_phlex_context)
167+
@phlex_context = opts.dig(:phlex, :context).dup
168+
end
169+
170+
# Sets the Phlex context.
163171
# @param context [Hash] The Phlex context to be set.
164-
# @return [Hash] The current Phlex context.
165-
def phlex_context(context = Undefined)
166-
case context
167-
when Undefined
168-
opts.dig(:phlex, :context)
169-
else
170-
opts[:phlex][:context] = context
171-
end
172+
# @return [Hash] The Phlex context that was set.
173+
def set_phlex_context(context)
174+
@_phlex_context = context
172175
end
173176

174177
# Renders a Phlex object.
175178
# @param obj [Phlex::SGML] The Phlex object to be rendered.
176-
# @param context [Hash] The Phlex context to be used for rendering.
179+
# @param layout [Class, nil] The layout to be used for rendering. Defaults to the layout set by {#phlex_layout}.
180+
# - The layout class will be initialized with the object and layout options from #{phlex_layout_opts} via {#phlex_layout_handler}.
181+
# - +nil+ or +false+ will disable layout and render the +obj+ directly.
182+
# @param context [Hash, nil] The Phlex context to be used for rendering. Defaults to the context set by {#phlex_context}.
177183
# @param content_type [String, nil] The content type of the response.
178184
# @param stream [Boolean] Whether to stream the response or not.
179-
def phlex(obj, context: phlex_context, content_type: nil, stream: false)
185+
def phlex(obj, layout: phlex_layout, context: phlex_context, content_type: nil, stream: false)
180186
raise TypeError.new(obj) unless obj.is_a?(::Phlex::SGML)
181187

182188
content_type ||= "image/svg+xml" if obj.is_a?(::Phlex::SVG)
183189
response["Content-Type"] = content_type if content_type
184190

185-
phlex_opts = opts[:phlex]
186-
renderer = if (layout = phlex_opts[:layout])
187-
phlex_layout_handler.call(layout, phlex_opts[:layout_opts], obj)
191+
renderer = if layout
192+
phlex_layout_handler.call(layout, phlex_layout_opts, obj)
188193
else
189194
obj
190195
end

spec/roda/phlex_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,36 @@
151151
expect(last_response.body).to include("<title>route-title</title>")
152152
end
153153
end
154+
155+
describe "mutating layout options" do
156+
before do
157+
@test_app_plugins = {phlex: {layout: AlternativeLayout, layout_opts: {title: +"Default"}}}
158+
end
159+
160+
it "carries to the next request when changing the plugin config" do
161+
get "/layout/mutate_opts/unsafe", {title: "A"}
162+
expect(last_response.body).to include("<title>A - Default</title>")
163+
164+
get "/layout/mutate_opts/unsafe", {title: "B"}
165+
expect(last_response.body).to include("<title>B - A - Default</title>")
166+
end
167+
168+
it "does not carry to the next request when changing the shallow copy" do
169+
get "/layout/mutate_opts/safer", {title: "A"}
170+
expect(last_response.body).to include("<title>A - Default</title>")
171+
172+
get "/layout/mutate_opts/safer", {title: "B"}
173+
expect(last_response.body).to include("<title>B - Default</title>")
174+
end
175+
176+
it "does not carry to the next request when creating a new config hash" do
177+
get "/layout/mutate_opts/safe", {title: "A"}
178+
expect(last_response.body).to include("<title>A - Default</title>")
179+
180+
get "/layout/mutate_opts/safe", {title: "B"}
181+
expect(last_response.body).to include("<title>B - Default</title>")
182+
end
183+
end
154184
end
155185

156186
context "when using layout_handler" do

spec/support/capybara_helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,5 @@ def needs_server?
2121
Capybara.register_driver(:needs_server) { NeedsServerDriver.new }
2222
Capybara.app = TestAppHelper.build_test_app(phlex: {}, streaming: {})
2323
Capybara.default_driver = :needs_server
24-
Capybara.server = :puma, {Silent: true}
24+
Capybara.server = :puma, {Silent: true, Threads: "1:1"}
2525
end

spec/support/test_app_helper.rb

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,29 +85,31 @@ def build_test_app(test_app_plugins = {})
8585
r.is do
8686
r.get do
8787
if (title = r.params["title"])
88-
phlex_layout_opts title: title
88+
set_phlex_layout_opts title: title
8989
end
9090
phlex FooView.new, content_type: "text/html"
9191
end
9292
end
9393

9494
r.get "nil" do
95-
phlex_layout nil
95+
set_phlex_layout nil
9696
phlex FooView.new
9797
end
9898

9999
r.get "false" do
100-
phlex_layout false
100+
set_phlex_layout false
101101
phlex FooView.new
102102
end
103103

104104
r.get "phlex_layout_override" do
105-
phlex_layout AlternativeLayout
106-
phlex_layout_opts title: "phlex_layout_override"
105+
set_phlex_layout AlternativeLayout
106+
set_phlex_layout_opts title: "phlex_layout_override"
107107
phlex FooView.new("content")
108108
end
109109

110110
r.on "merge_opts_route_override" do
111+
# Directly mutating the plugin config is not recommended.
112+
# Use `set_phlex_layout` and `set_phlex_layout_opts` instead.
111113
opts[:phlex][:layout] = AlternativeLayout
112114
opts[:phlex][:layout_opts] = {title: "route-title"}
113115

@@ -118,11 +120,37 @@ def build_test_app(test_app_plugins = {})
118120
phlex FooView.new, content_type: "text/html"
119121
end
120122
end
123+
124+
r.on "mutate_opts" do
125+
r.get "unsafe" do
126+
if (title = r.params["title"])
127+
# DON'T do this. This will mutate the plugin config and carry over to the next request.
128+
phlex_layout_opts[:title].prepend title, " - "
129+
end
130+
phlex FooView.new, content_type: "text/html"
131+
end
132+
133+
r.get "safer" do
134+
if (title = r.params["title"])
135+
# Mutating the shallow copy of layout options
136+
phlex_layout_opts[:title] = phlex_layout_opts[:title].dup.prepend title, " - "
137+
end
138+
phlex FooView.new, content_type: "text/html"
139+
end
140+
141+
r.get "safe" do
142+
if (title = r.params["title"])
143+
# Setting a new layout options hash
144+
set_phlex_layout_opts phlex_layout_opts.merge(title: "#{title} - #{phlex_layout_opts[:title]}")
145+
end
146+
phlex FooView.new, content_type: "text/html"
147+
end
148+
end
121149
end
122150

123151
r.on "layout_handler" do
124-
phlex_layout AlternativeLayout
125-
phlex_layout_handler ->(layout, _opts, obj) {
152+
set_phlex_layout AlternativeLayout
153+
set_phlex_layout_handler ->(layout, _opts, obj) {
126154
layout.new(obj, title: "phlex_layout_handler override")
127155
}
128156

@@ -131,12 +159,12 @@ def build_test_app(test_app_plugins = {})
131159
end
132160

133161
r.get "reset_via_nil" do
134-
phlex_layout_handler nil
162+
set_phlex_layout_handler nil
135163
phlex FooView.new
136164
end
137165

138166
r.get "reset_via_default" do
139-
phlex_layout_handler :default
167+
set_phlex_layout_handler :default
140168
phlex FooView.new
141169
end
142170
end

0 commit comments

Comments
 (0)