forest_liana 9.20.9 → 9.20.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8081936e6d6a125213c778a94576d1eaaf5560eb838a89563a6e113ab79a2e46
4
- data.tar.gz: b5cb7df552d00872c40981ee37009b534bb5a254ee637d7dd39dd71b840f6124
3
+ metadata.gz: 5449af5d3c0aa87c4c56f8417d3527e279690d22a34f02cb081cee74d2de06b1
4
+ data.tar.gz: '044797af4de1b516a177d859566fdbdea4e5458eb938c107077d01a19314a763'
5
5
  SHA512:
6
- metadata.gz: a1e231f576b02caed5a80f12fff4d6baabd4355ef9f5aae79afb3f4a6663e29cda2cdd078088e4949d42435c1bcf8f28b82bc7c90c619f971e17da7fa81bd45a
7
- data.tar.gz: 0a93106150c9ead61198c464baaab09c0eb0974abc5c78570e980baa48bd3a05bdb4677720fa8b35704375665c06b297cad763325249601d08962222a9c444d4
6
+ metadata.gz: cce3c3edaacf3cea3dfc9ea29f3a7391cf2e1a09b399784c1be06c767d6013e600d70c7ca2fa70673451c0e18431df0d521e3cff2285408c003ef4911ca18336
7
+ data.tar.gz: b56dc37e18b8f8e78e730c1d8eb844731134d22be8478a27e4ea576ff75df9523f6c6ba7b7da8b07a585b97419d9b2d3058656527acad2dcb94146533e9afabd
@@ -81,4 +81,29 @@ class ForestLiana::Model::Action
81
81
  def persisted?
82
82
  false
83
83
  end
84
+
85
+ # Normalized at the boundary so every consumer sees one canonical value: the schema sent to
86
+ # Forest (the UI appends /hooks/load to it), the permission lookup comparing request paths,
87
+ # and the routes drawn from it. Leading slash kept as declared — consumers tolerate both.
88
+ def endpoint=(value)
89
+ @endpoint = value && value.to_s.gsub(%r{/+}, '/').delete_suffix('/')
90
+ end
91
+
92
+ def endpoint_under_forest_mount?
93
+ normalized_endpoint.start_with?('forest/')
94
+ end
95
+
96
+ def engine_relative_endpoint
97
+ normalized_endpoint.delete_prefix('forest')
98
+ end
99
+
100
+ def absolute_endpoint
101
+ "/#{normalized_endpoint}"
102
+ end
103
+
104
+ private
105
+
106
+ def normalized_endpoint
107
+ endpoint.to_s.delete_prefix('/')
108
+ end
84
109
  end
@@ -1,10 +1,13 @@
1
1
  ForestLiana.apimap.each do |collection|
2
2
  if !collection.actions.empty?
3
3
  collection.actions.each do |action|
4
+ # Endpoints outside the mount are routed on the host app instead — see ForestLiana::Engine.
5
+ next unless action.endpoint_under_forest_mount?
6
+
4
7
  # Unconditional: clients probe /hooks/load even when no load hook is declared.
5
- post action.endpoint.sub('forest', '') + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
8
+ post action.engine_relative_endpoint + '/hooks/load' => 'actions#load', action_name: ActiveSupport::Inflector.parameterize(action.name)
6
9
  if action.hooks && action.hooks[:change].present?
7
- post action.endpoint.sub('forest', '') + '/hooks/change' => 'actions#change', action_name: ActiveSupport::Inflector.parameterize(action.name)
10
+ post action.engine_relative_endpoint + '/hooks/change' => 'actions#change', action_name: ActiveSupport::Inflector.parameterize(action.name)
8
11
  end
9
12
  end
10
13
  end
@@ -127,6 +127,32 @@ module ForestLiana
127
127
  end
128
128
  end
129
129
  end
130
+
131
+ ForestLiana::Engine.register_out_of_mount_hook_routes
132
+ end
133
+
134
+ # An endpoint outside the engine mount can only be served by the host router: a route drawn in
135
+ # the engine gets prefixed with the mount, where no client ever calls. Prepended so a host
136
+ # catch-all (SPA fallback) cannot shadow the hooks, and registered from after_initialize rather
137
+ # than from config/routes/actions.rb, which is re-evaluated on every reload and would stack a
138
+ # duplicate block per pass — prepended blocks persist across reloads on their own. Defined here
139
+ # so the block's closure captures this empty scope, not after_initialize's locals: the block is
140
+ # retained by the router for the process lifetime and would otherwise pin the bootstrapper.
141
+ def self.register_out_of_mount_hook_routes
142
+ Rails.application.routes.prepend do
143
+ ForestLiana.apimap.each do |collection|
144
+ collection.actions.each do |action|
145
+ next if action.endpoint_under_forest_mount?
146
+
147
+ post "#{action.absolute_endpoint}/hooks/load" => 'forest_liana/actions#load',
148
+ action_name: ActiveSupport::Inflector.parameterize(action.name)
149
+ if action.hooks && action.hooks[:change].present?
150
+ post "#{action.absolute_endpoint}/hooks/change" => 'forest_liana/actions#change',
151
+ action_name: ActiveSupport::Inflector.parameterize(action.name)
152
+ end
153
+ end
154
+ end
155
+ end
130
156
  end
131
157
  end
132
158
  end
@@ -1,3 +1,3 @@
1
1
  module ForestLiana
2
- VERSION = "9.20.9"
2
+ VERSION = "9.20.10"
3
3
  end
@@ -0,0 +1,5 @@
1
+ class HostCatchAllController < ActionController::Base
2
+ def index
3
+ render json: { caught_by: 'host catch-all' }
4
+ end
5
+ end
@@ -7,4 +7,7 @@ Rails.application.routes.draw do
7
7
  end
8
8
 
9
9
  mount ForestLiana::Engine => "/forest"
10
+
11
+ # Stands in for a host SPA fallback: hooks routes on out-of-mount endpoints must still win.
12
+ match '/api/*path', to: 'host_catch_all#index', via: :all
10
13
  end
@@ -180,6 +180,20 @@ class Forest::Island
180
180
  }
181
181
  }
182
182
 
183
+ action 'out_of_mount_action',
184
+ endpoint: '/api/custom/islands/out_of_mount_action',
185
+ fields: [foo],
186
+ hooks: {
187
+ :load => -> (context) {
188
+ context[:fields]
189
+ },
190
+ :change => {
191
+ 'on_foo_changed' => -> (context) {
192
+ context[:fields]
193
+ }
194
+ }
195
+ }
196
+
183
197
  action 'multiple_enums_action',
184
198
  fields: [foo, multiple_enum],
185
199
  hooks: {
@@ -0,0 +1,48 @@
1
+ module ForestLiana
2
+ describe Model::Action do
3
+ def action_with(endpoint)
4
+ Model::Action.new(name: 'an action', endpoint: endpoint)
5
+ end
6
+
7
+ describe 'endpoint routing' do
8
+ it 'defaults to an endpoint under the mount, with or without a leading slash' do
9
+ action = Model::Action.new(name: 'an action')
10
+
11
+ expect(action.endpoint).to eq('forest/actions/an-action')
12
+ expect(action.endpoint_under_forest_mount?).to be true
13
+ expect(action.engine_relative_endpoint).to eq('/actions/an-action')
14
+
15
+ expect(action_with('/forest/actions/foo').endpoint_under_forest_mount?).to be true
16
+ expect(action_with('/forest/actions/foo').engine_relative_endpoint).to eq('/actions/foo')
17
+ end
18
+
19
+ it 'routes an endpoint outside the mount on the host, verbatim' do
20
+ action = action_with('/domains/organizations/suspensions/suspend')
21
+
22
+ expect(action.endpoint_under_forest_mount?).to be false
23
+ expect(action.absolute_endpoint).to eq('/domains/organizations/suspensions/suspend')
24
+ end
25
+
26
+ # A trailing or doubled slash would otherwise desynchronize the three consumers of the
27
+ # declared endpoint: the drawn route, the schema the UI appends /hooks/load to, and the
28
+ # permission lookup comparing request paths.
29
+ it 'normalizes redundant slashes at assignment, so every consumer sees one value' do
30
+ expect(action_with('/domains/x/suspend/').endpoint).to eq('/domains/x/suspend')
31
+ expect(action_with('//domains/x/suspend').endpoint).to eq('/domains/x/suspend')
32
+ expect(action_with('/domains/x/suspend/').absolute_endpoint).to eq('/domains/x/suspend')
33
+ expect(action_with('domains/x/suspend').endpoint).to eq('domains/x/suspend')
34
+ expect(action_with('/forest/actions/foo/').engine_relative_endpoint).to eq('/actions/foo')
35
+ expect(action_with('//forest/actions/foo').endpoint_under_forest_mount?).to be true
36
+ end
37
+
38
+ # `sub('forest', '')` used to match anywhere, mangling these into '/ry/actions/x' and '/my//x'.
39
+ it 'does not treat a path merely containing "forest" as being under the mount' do
40
+ expect(action_with('forestry/actions/x').endpoint_under_forest_mount?).to be false
41
+ expect(action_with('forestry/actions/x').absolute_endpoint).to eq('/forestry/actions/x')
42
+
43
+ expect(action_with('my/forest/x').endpoint_under_forest_mount?).to be false
44
+ expect(action_with('my/forest/x').absolute_endpoint).to eq('/my/forest/x')
45
+ end
46
+ end
47
+ end
48
+ end
@@ -170,6 +170,53 @@ describe 'Requesting Actions routes', :type => :request do
170
170
  end
171
171
  end
172
172
 
173
+ describe 'on an endpoint declared outside the engine mount' do
174
+ params = {
175
+ data: {
176
+ attributes: { ids: [1], collection_name: 'Island' }
177
+ }
178
+ }
179
+
180
+ # The dummy app declares a `/api/*path` catch-all standing in for a host SPA fallback: these
181
+ # routes have to outrank it, or the 404 this fix removes comes straight back.
182
+ it 'should serve /load at the endpoint the client calls' do
183
+ post '/api/custom/islands/out_of_mount_action/hooks/load', params: JSON.dump(params), headers: headers
184
+ expect(response.status).to eq(200)
185
+ expect(JSON.parse(response.body)['fields'].map { |field| field['field'] }).to eq(['foo'])
186
+ end
187
+
188
+ it 'should let the host catch-all keep every other path under it' do
189
+ post '/api/custom/islands/something_else', params: JSON.dump(params), headers: headers
190
+ expect(JSON.parse(response.body)).to eq({'caught_by' => 'host catch-all'})
191
+ end
192
+
193
+ it 'should serve /change at the endpoint the client calls' do
194
+ action = island.actions.select { |action| action.name == 'out_of_mount_action' }.first
195
+ foo = action.fields.select { |field| field[:field] == 'foo' }.first
196
+ updated_foo = foo.clone.merge({:previousValue => nil, :value => 'bar'})
197
+ p = {
198
+ data: {
199
+ attributes: {
200
+ ids: [1],
201
+ fields: [updated_foo],
202
+ collection_name: 'Island',
203
+ changed_field: 'foo'
204
+ }
205
+ }
206
+ }
207
+
208
+ post '/api/custom/islands/out_of_mount_action/hooks/change', params: JSON.dump(p), headers: headers
209
+ expect(response.status).to eq(200)
210
+ expect(JSON.parse(response.body)['fields'].map { |field| field['field'] }).to eq(['foo'])
211
+ end
212
+
213
+ it 'should not expose the hooks under the engine mount' do
214
+ expect {
215
+ post '/forest/api/custom/islands/out_of_mount_action/hooks/load', params: JSON.dump(params), headers: headers
216
+ }.to raise_error(ActionController::RoutingError)
217
+ end
218
+ end
219
+
173
220
  describe 'call /change' do
174
221
  it 'should respond 200' do
175
222
  action = island.actions.select { |action| action.name == 'my_action' }.first
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: forest_liana
3
3
  version: !ruby/object:Gem::Version
4
- version: 9.20.9
4
+ version: 9.20.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sandro Munda
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-05 00:00:00.000000000 Z
11
+ date: 2026-08-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -371,6 +371,7 @@ files:
371
371
  - spec/dummy/app/config/routes.rb
372
372
  - spec/dummy/app/controllers/application_controller.rb
373
373
  - spec/dummy/app/controllers/forest/islands_controller.rb
374
+ - spec/dummy/app/controllers/host_catch_all_controller.rb
374
375
  - spec/dummy/app/helpers/application_helper.rb
375
376
  - spec/dummy/app/models/address.rb
376
377
  - spec/dummy/app/models/application_record.rb
@@ -441,6 +442,7 @@ files:
441
442
  - spec/lib/forest_liana/collection_spec.rb
442
443
  - spec/lib/forest_liana/engine_spec.rb
443
444
  - spec/lib/forest_liana/schema_file_updater_spec.rb
445
+ - spec/models/forest_liana/model/action_spec.rb
444
446
  - spec/rails_helper.rb
445
447
  - spec/requests/actions_controller_spec.rb
446
448
  - spec/requests/authentications_spec.rb
@@ -684,6 +686,7 @@ test_files:
684
686
  - spec/dummy/app/config/routes.rb
685
687
  - spec/dummy/app/controllers/application_controller.rb
686
688
  - spec/dummy/app/controllers/forest/islands_controller.rb
689
+ - spec/dummy/app/controllers/host_catch_all_controller.rb
687
690
  - spec/dummy/app/helpers/application_helper.rb
688
691
  - spec/dummy/app/models/address.rb
689
692
  - spec/dummy/app/models/application_record.rb
@@ -754,6 +757,7 @@ test_files:
754
757
  - spec/lib/forest_liana/collection_spec.rb
755
758
  - spec/lib/forest_liana/engine_spec.rb
756
759
  - spec/lib/forest_liana/schema_file_updater_spec.rb
760
+ - spec/models/forest_liana/model/action_spec.rb
757
761
  - spec/rails_helper.rb
758
762
  - spec/requests/actions_controller_spec.rb
759
763
  - spec/requests/authentications_spec.rb