inertia_hanami 0.1.0
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 +7 -0
- data/.rspec +1 -0
- data/CHANGELOG.md +18 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/PLAN.md +193 -0
- data/README.md +417 -0
- data/Rakefile +16 -0
- data/lib/generators/inertia_hanami/install_generator.rb +216 -0
- data/lib/generators/inertia_hanami/templates/helpers.rb.erb +12 -0
- data/lib/generators/inertia_hanami/templates/layout.html.erb.erb +15 -0
- data/lib/generators/inertia_hanami/templates/provider.rb.erb +5 -0
- data/lib/generators/inertia_hanami/templates/sample_action.rb.erb +15 -0
- data/lib/generators/inertia_hanami/templates/sample_template.html.erb.erb +1 -0
- data/lib/generators/inertia_hanami/templates/sample_view.rb.erb +13 -0
- data/lib/inertia_hanami/action.rb +214 -0
- data/lib/inertia_hanami/asset_version.rb +23 -0
- data/lib/inertia_hanami/cli/commands/install.rb +29 -0
- data/lib/inertia_hanami/cli.rb +16 -0
- data/lib/inertia_hanami/configuration.rb +22 -0
- data/lib/inertia_hanami/helper.rb +50 -0
- data/lib/inertia_hanami/middleware/csrf.rb +63 -0
- data/lib/inertia_hanami/middleware/redirects.rb +67 -0
- data/lib/inertia_hanami/middleware/version.rb +37 -0
- data/lib/inertia_hanami/prop_evaluator.rb +32 -0
- data/lib/inertia_hanami/props.rb +68 -0
- data/lib/inertia_hanami/protocol_builder.rb +194 -0
- data/lib/inertia_hanami/provider.rb +33 -0
- data/lib/inertia_hanami/renderer.rb +92 -0
- data/lib/inertia_hanami/request_context.rb +75 -0
- data/lib/inertia_hanami/ssr_renderer.rb +66 -0
- data/lib/inertia_hanami/testing/rspec.rb +265 -0
- data/lib/inertia_hanami/version.rb +5 -0
- data/lib/inertia_hanami.rb +24 -0
- data/sig/inertia_hanami.rbs +4 -0
- metadata +190 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module <%= namespace %>
|
|
4
|
+
module Actions
|
|
5
|
+
module InertiaExample
|
|
6
|
+
class Show < <%= namespace %>::Action
|
|
7
|
+
include InertiaHanami::Action
|
|
8
|
+
|
|
9
|
+
def handle(_request, _response)
|
|
10
|
+
inertia_render("InertiaExample/Show", props: { greeting: "Hello from inertia_hanami" })
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%%# Inertia renders this page client-side; the layout's inertia_root(page:) carries the data. %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module <%= namespace %>
|
|
4
|
+
module Views
|
|
5
|
+
module InertiaExample
|
|
6
|
+
class Show < <%= namespace %>::View
|
|
7
|
+
expose :page, layout: true
|
|
8
|
+
expose :ssr_head, layout: true
|
|
9
|
+
expose :ssr_body, layout: true
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InertiaHanami
|
|
4
|
+
# Include-able into `Hanami::Action` subclasses to speak the Inertia
|
|
5
|
+
# protocol: skips Hanami's automatic view rendering for Inertia XHR
|
|
6
|
+
# requests, renders the Inertia page envelope, accumulates shared props,
|
|
7
|
+
# and handles the external-redirect (409 + X-Inertia-Location) case.
|
|
8
|
+
#
|
|
9
|
+
# Follows Hanami's own composition idiom (`include Inertia::Action`)
|
|
10
|
+
# rather than requiring a subclass of a framework-specific base class.
|
|
11
|
+
module Action
|
|
12
|
+
# Session key used to stash validation errors across a redirect, mirroring
|
|
13
|
+
# inertia-rails' `session[:inertia_errors]`. Cleared as soon as it's read
|
|
14
|
+
# into the next response's `errors` prop.
|
|
15
|
+
INERTIA_ERRORS_SESSION_KEY = "inertia_errors"
|
|
16
|
+
|
|
17
|
+
def self.included(action_class)
|
|
18
|
+
super
|
|
19
|
+
|
|
20
|
+
action_class.extend(ClassMethods)
|
|
21
|
+
action_class.include(InstanceMethods)
|
|
22
|
+
|
|
23
|
+
action_class.append_before do |req, res|
|
|
24
|
+
@inertia_context[:request] = req
|
|
25
|
+
@inertia_context[:response] = res
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
share_errors_and_flash(action_class)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Auto-shared on every request: `errors` (from the session, stashed via
|
|
32
|
+
# `share_inertia_errors`) and `flash` (from Hanami's flash). Registered
|
|
33
|
+
# before any `inertia_share` calls in the including class's own body, so
|
|
34
|
+
# those can override either key. No-ops entirely when the app hasn't
|
|
35
|
+
# enabled sessions (`session_enabled?` defaults to `false` on
|
|
36
|
+
# `Hanami::Action`).
|
|
37
|
+
def self.share_errors_and_flash(action_class)
|
|
38
|
+
action_class.inertia_share do
|
|
39
|
+
next {} unless session_enabled?
|
|
40
|
+
|
|
41
|
+
{ errors: inertia_errors_prop, flash: inertia_flash_prop }.compact
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
private_class_method :share_errors_and_flash
|
|
45
|
+
|
|
46
|
+
# Class-level `inertia_share` macro, inherited down subclasses.
|
|
47
|
+
module ClassMethods
|
|
48
|
+
def inertia_shared_props
|
|
49
|
+
inherited_inertia_shared_props.merge(@inertia_shared_props ||= {})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inertia_shared_blocks
|
|
53
|
+
inherited_inertia_shared_blocks + (@inertia_shared_blocks ||= [])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def inertia_share(**props, &block)
|
|
57
|
+
(@inertia_shared_props ||= {}).merge!(props)
|
|
58
|
+
(@inertia_shared_blocks ||= []) << block if block
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Class-level default for whether pages rendered by this action (and
|
|
62
|
+
# its subclasses, unless they override it) should have their Inertia
|
|
63
|
+
# history entry encrypted client-side. Falls back to the global
|
|
64
|
+
# `Hanami.app["inertia.config"].encrypt_history` default when unset
|
|
65
|
+
# anywhere in the ancestry (see `inertia_encrypt_history?`).
|
|
66
|
+
def encrypt_history(value: true)
|
|
67
|
+
@inertia_encrypt_history = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def inertia_encrypt_history?
|
|
71
|
+
return @inertia_encrypt_history if defined?(@inertia_encrypt_history)
|
|
72
|
+
|
|
73
|
+
inherited_inertia_encrypt_history?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def inherited_inertia_shared_props
|
|
79
|
+
superclass.respond_to?(:inertia_shared_props) ? superclass.inertia_shared_props : {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def inherited_inertia_shared_blocks
|
|
83
|
+
superclass.respond_to?(:inertia_shared_blocks) ? superclass.inertia_shared_blocks : []
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def inherited_inertia_encrypt_history?
|
|
87
|
+
superclass.respond_to?(:inertia_encrypt_history?) ? superclass.inertia_encrypt_history? : nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Instance-level Inertia API mixed into the including action class.
|
|
92
|
+
module InstanceMethods
|
|
93
|
+
# Runs before the object is frozen (Hanami::Action freezes instances at
|
|
94
|
+
# the end of #initialize), so @inertia_context can hold a mutable Hash
|
|
95
|
+
# to stash the request/response for later use by frozen instance methods.
|
|
96
|
+
#
|
|
97
|
+
# Deliberately `(**kwargs)` and not `(...)`: dry-auto_inject's
|
|
98
|
+
# MethodParameters treats `(...)` as a full signature rather than a
|
|
99
|
+
# pass-through to skip past (mirroring ROM's delegation convention), so
|
|
100
|
+
# with `(...)` it stops here instead of reaching `Hanami::Action#initialize`
|
|
101
|
+
# - any `Deps[]` dependency keyword then gets forwarded all the way down
|
|
102
|
+
# and rejected as unknown. `(**kwargs)` is recognized as pass-through.
|
|
103
|
+
def initialize(**kwargs)
|
|
104
|
+
@inertia_context = {}
|
|
105
|
+
super(**kwargs)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def auto_render?(res)
|
|
109
|
+
return false if RequestContext.new(res.env).inertia?
|
|
110
|
+
|
|
111
|
+
super
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def inertia_share(**props, &block)
|
|
115
|
+
instance_props = (@inertia_context[:instance_shared_props] ||= {})
|
|
116
|
+
instance_props.merge!(props)
|
|
117
|
+
(@inertia_context[:instance_shared_blocks] ||= []) << block if block
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Instance-level override of the class's `encrypt_history` default,
|
|
121
|
+
# for a single action instance.
|
|
122
|
+
def encrypt_history(value: true)
|
|
123
|
+
@inertia_context[:instance_encrypt_history] = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Marks the next `inertia_render` call's response as `clearHistory:
|
|
127
|
+
# true`, telling the client to wipe any encrypted history it has
|
|
128
|
+
# stored (e.g. call this before redirecting on logout).
|
|
129
|
+
def clear_history
|
|
130
|
+
@inertia_context[:instance_clear_history] = true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def inertia_render(component, props: {}, url: nil, version: nil,
|
|
134
|
+
encrypt_history: inertia_history_encrypted?, clear_history: inertia_history_cleared?)
|
|
135
|
+
Renderer.new(
|
|
136
|
+
request: @inertia_context[:request],
|
|
137
|
+
response: @inertia_context[:response],
|
|
138
|
+
component: component,
|
|
139
|
+
props: inertia_collected_props.merge(props),
|
|
140
|
+
url: url,
|
|
141
|
+
version: version || Hanami.app["inertia.config"].version,
|
|
142
|
+
encrypt_history: encrypt_history,
|
|
143
|
+
clear_history: clear_history
|
|
144
|
+
).render
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def inertia_location(url)
|
|
148
|
+
request = @inertia_context[:request]
|
|
149
|
+
response = @inertia_context[:response]
|
|
150
|
+
|
|
151
|
+
if RequestContext.new(request.env).inertia?
|
|
152
|
+
response.headers["X-Inertia-Location"] = url
|
|
153
|
+
halt(409)
|
|
154
|
+
else
|
|
155
|
+
response.redirect_to(url)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Stashes `errors` in the session so they surface as the `errors` prop
|
|
160
|
+
# on the next request's Inertia page (e.g. after a validation failure
|
|
161
|
+
# redirect). Analogous to inertia-rails' `redirect_to ..., inertia: {
|
|
162
|
+
# errors: ... }`.
|
|
163
|
+
def share_inertia_errors(errors)
|
|
164
|
+
hash = errors.respond_to?(:to_h) ? errors.to_h : errors
|
|
165
|
+
@inertia_context[:request].session[INERTIA_ERRORS_SESSION_KEY] = hash
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def inertia_history_encrypted?
|
|
171
|
+
instance_value = @inertia_context[:instance_encrypt_history]
|
|
172
|
+
return instance_value unless instance_value.nil?
|
|
173
|
+
|
|
174
|
+
class_value = self.class.inertia_encrypt_history?
|
|
175
|
+
return class_value unless class_value.nil?
|
|
176
|
+
|
|
177
|
+
Hanami.app["inertia.config"].encrypt_history
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def inertia_history_cleared?
|
|
181
|
+
@inertia_context[:instance_clear_history] || false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def inertia_collected_props
|
|
185
|
+
props = self.class.inertia_shared_props.dup
|
|
186
|
+
self.class.inertia_shared_blocks.each { |block| props.merge!(instance_exec(&block)) }
|
|
187
|
+
props.merge!(@inertia_context[:instance_shared_props]) if @inertia_context[:instance_shared_props]
|
|
188
|
+
(@inertia_context[:instance_shared_blocks] || []).each { |block| props.merge!(instance_exec(&block)) }
|
|
189
|
+
props
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Reads and clears any errors stashed via `share_inertia_errors`, so
|
|
193
|
+
# they're delivered exactly once. Falls back to an empty hash when
|
|
194
|
+
# `always_include_errors_hash` is enabled, or is omitted (nil) entirely
|
|
195
|
+
# otherwise.
|
|
196
|
+
def inertia_errors_prop
|
|
197
|
+
stashed = @inertia_context[:request].session.delete(INERTIA_ERRORS_SESSION_KEY)
|
|
198
|
+
return Props::Always.new(block: -> { stashed }) if stashed
|
|
199
|
+
|
|
200
|
+
return nil unless Hanami.app["inertia.config"].always_include_errors_hash
|
|
201
|
+
|
|
202
|
+
Props::Always.new(block: -> { {} })
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Shares the current request's flash messages, when there are any.
|
|
206
|
+
def inertia_flash_prop
|
|
207
|
+
flash = @inertia_context[:response].flash.now
|
|
208
|
+
return nil if flash.empty?
|
|
209
|
+
|
|
210
|
+
Props::Always.new(block: -> { flash })
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module InertiaHanami
|
|
7
|
+
# Derives an Inertia asset version string from hanami-assets' assets.json
|
|
8
|
+
# manifest, mirroring the role ViteRuby.digest plays for inertia-rails.
|
|
9
|
+
module AssetVersion
|
|
10
|
+
MANIFEST_FILENAME = "assets.json"
|
|
11
|
+
|
|
12
|
+
# Returns a SHA256 hex digest of the assets.json manifest under `assets_root`,
|
|
13
|
+
# or nil if no manifest is present (e.g. assets not yet compiled).
|
|
14
|
+
def self.digest(assets_root)
|
|
15
|
+
return nil if assets_root.nil?
|
|
16
|
+
|
|
17
|
+
manifest_path = Pathname(assets_root).join(MANIFEST_FILENAME)
|
|
18
|
+
return nil unless manifest_path.file?
|
|
19
|
+
|
|
20
|
+
Digest::SHA256.file(manifest_path).hexdigest
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "generators/inertia_hanami/install_generator"
|
|
4
|
+
|
|
5
|
+
module InertiaHanami
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
# `hanami generate inertia:install` - scaffolds the provider, middleware
|
|
9
|
+
# wiring, layout, view helper, and a sample page for inertia_hanami.
|
|
10
|
+
class Install < Hanami::CLI::Commands::App::Command
|
|
11
|
+
option :force, type: :flag, default: false, desc: "Overwrite existing files during generation"
|
|
12
|
+
option :framework, default: "react", values: InertiaHanami::Generators::InstallGenerator::FRAMEWORK_PACKAGES.keys,
|
|
13
|
+
desc: "Frontend framework for @inertiajs/* package.json guidance"
|
|
14
|
+
|
|
15
|
+
example [
|
|
16
|
+
%( (scaffolds provider, layout, helper, sample page, react npm guidance)),
|
|
17
|
+
%(--framework=vue (npm guidance targets @inertiajs/vue3 instead)),
|
|
18
|
+
%(--force (overwrite files this command previously generated))
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def call(force:, framework:, **)
|
|
22
|
+
InertiaHanami::Generators::InstallGenerator.new(
|
|
23
|
+
fs: fs, inflector: inflector, out: out
|
|
24
|
+
).call(base_path: app.root, namespace: app.namespace, framework: framework, force: force)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Registers `hanami generate inertia:install` with the Hanami CLI.
|
|
4
|
+
#
|
|
5
|
+
# Only loaded when `Hanami::CLI` is already defined (see
|
|
6
|
+
# `inertia_hanami.rb`) - i.e. when this gem is required from Bundler's
|
|
7
|
+
# `:cli` group, the same convention `hanami-reloader` and `hanami-rspec`
|
|
8
|
+
# use to hook into `hanami generate`/`hanami server` without hanami-cli
|
|
9
|
+
# needing to know about third-party gems in advance.
|
|
10
|
+
|
|
11
|
+
require "hanami/cli"
|
|
12
|
+
require "inertia_hanami/cli/commands/install"
|
|
13
|
+
|
|
14
|
+
if Hanami::CLI.within_hanami_app?
|
|
15
|
+
Hanami::CLI.register("generate inertia:install", InertiaHanami::CLI::Commands::Install)
|
|
16
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/configurable"
|
|
4
|
+
|
|
5
|
+
module InertiaHanami
|
|
6
|
+
class Configuration
|
|
7
|
+
include Dry::Configurable
|
|
8
|
+
|
|
9
|
+
setting :version, default: nil
|
|
10
|
+
setting :root_view, default: "app"
|
|
11
|
+
setting :root_dom_id, default: "app"
|
|
12
|
+
setting :component_path_resolver, default: ->(component) { component }
|
|
13
|
+
setting :always_include_errors_hash, default: false
|
|
14
|
+
setting :encrypt_history, default: false
|
|
15
|
+
|
|
16
|
+
setting :ssr do
|
|
17
|
+
setting :enabled, default: false
|
|
18
|
+
setting :url, default: "http://localhost:13714"
|
|
19
|
+
setting :raise_on_error, default: false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
require "hanami/view/html"
|
|
6
|
+
|
|
7
|
+
module InertiaHanami
|
|
8
|
+
# View helper that renders the Inertia root element for the initial
|
|
9
|
+
# full-page load. Include into a view/scope class, or call directly from
|
|
10
|
+
# an ERB template/layout.
|
|
11
|
+
module Helper
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Renders a `<script data-page="app" type="application/json">` tag
|
|
15
|
+
# holding the page JSON, plus the empty `<div id="app">` mount point.
|
|
16
|
+
# `@inertiajs/react|vue3|svelte`'s `createInertiaApp` (since Inertia
|
|
17
|
+
# v3) only reads the initial page from that script tag - it no longer
|
|
18
|
+
# falls back to a `data-page` attribute on the mount div - so this is
|
|
19
|
+
# the only form current clients will actually pick up.
|
|
20
|
+
def inertia_root(page:, id: nil)
|
|
21
|
+
root_id = CGI.escapeHTML((id || Hanami.app["inertia.config"].root_dom_id).to_s)
|
|
22
|
+
# Marked html_safe so Hanami::View's ERB engine doesn't re-escape the
|
|
23
|
+
# tags themselves. A `<script>` element's content is raw text per the
|
|
24
|
+
# HTML parsing spec - entities inside it are never decoded - so the
|
|
25
|
+
# JSON must NOT be HTML-entity-escaped (that would corrupt it before
|
|
26
|
+
# JSON.parse ever sees it); the only real risk is a literal
|
|
27
|
+
# `</script` sequence prematurely closing the tag, guarded separately.
|
|
28
|
+
<<~HTML.html_safe
|
|
29
|
+
<script data-page="#{root_id}" type="application/json">#{escape_script_content(page.to_json)}</script>
|
|
30
|
+
<div id="#{root_id}"></div>
|
|
31
|
+
HTML
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def escape_script_content(json)
|
|
35
|
+
json.gsub("</", '<\/')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Renders the `<head>` markup returned by the SSR server. Belongs inside
|
|
39
|
+
# the layout's `<head>` tag.
|
|
40
|
+
def inertia_ssr_head(head)
|
|
41
|
+
head.to_s.html_safe
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Renders the `<body>` markup returned by the SSR server, in place of
|
|
45
|
+
# the CSR `inertia_root` div.
|
|
46
|
+
def inertia_ssr_body(body)
|
|
47
|
+
body.to_s.html_safe
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
|
|
6
|
+
module InertiaHanami
|
|
7
|
+
module Middleware
|
|
8
|
+
# Bridges Inertia's client-side CSRF handshake to Hanami's own
|
|
9
|
+
# Hanami::Action::CSRFProtection, without reimplementing either side:
|
|
10
|
+
#
|
|
11
|
+
# - Inertia's HTTP client automatically reads an XSRF-TOKEN cookie and
|
|
12
|
+
# echoes it back as an X-XSRF-TOKEN header on every request.
|
|
13
|
+
# - Hanami::Action::CSRFProtection stores its challenge token in the
|
|
14
|
+
# session and only checks for it via the `_csrf_token` param or an
|
|
15
|
+
# X-CSRF-Token header — it never exposes the token as a cookie.
|
|
16
|
+
#
|
|
17
|
+
# This middleware translates between the two: an incoming X-XSRF-TOKEN
|
|
18
|
+
# header is copied into X-CSRF-Token before the request reaches the
|
|
19
|
+
# action, and once the action has minted/reused the session's CSRF
|
|
20
|
+
# token, it's mirrored into a readable XSRF-TOKEN cookie on the way out.
|
|
21
|
+
class Csrf
|
|
22
|
+
COOKIE_NAME = "XSRF-TOKEN"
|
|
23
|
+
# Hanami::Action::Request::Session#[]= always stringifies keys before
|
|
24
|
+
# writing to the underlying rack.session store, regardless of the
|
|
25
|
+
# symbol Hanami::Action::CSRFProtection itself indexes with - so the
|
|
26
|
+
# token lands under this string key, not :_csrf_token.
|
|
27
|
+
SESSION_KEY = "_csrf_token"
|
|
28
|
+
|
|
29
|
+
def initialize(app)
|
|
30
|
+
@app = app
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(env)
|
|
34
|
+
bridge_incoming_token(env)
|
|
35
|
+
|
|
36
|
+
status, headers, body = @app.call(env)
|
|
37
|
+
|
|
38
|
+
expose_outgoing_token(env, headers)
|
|
39
|
+
|
|
40
|
+
[status, headers, body]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def bridge_incoming_token(env)
|
|
46
|
+
token = env["HTTP_X_XSRF_TOKEN"]
|
|
47
|
+
env["HTTP_X_CSRF_TOKEN"] ||= token if token
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def expose_outgoing_token(env, headers)
|
|
51
|
+
session = env["rack.session"]
|
|
52
|
+
token = session && session[SESSION_KEY]
|
|
53
|
+
return unless token
|
|
54
|
+
|
|
55
|
+
request = Rack::Request.new(env)
|
|
56
|
+
Rack::Utils.set_cookie_header!(
|
|
57
|
+
headers, COOKIE_NAME,
|
|
58
|
+
{ value: token, path: "/", secure: request.ssl?, same_site: :lax }
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module InertiaHanami
|
|
7
|
+
module Middleware
|
|
8
|
+
# Plain Rack middleware implementing the Inertia protocol's redirect
|
|
9
|
+
# mechanics:
|
|
10
|
+
#
|
|
11
|
+
# - 301/302 redirects issued in response to a PUT/PATCH/DELETE request are
|
|
12
|
+
# rewritten to 303, so the client's follow-up GET doesn't resubmit the
|
|
13
|
+
# original request body.
|
|
14
|
+
# - Redirects to an external origin are rewritten to a 409 response with
|
|
15
|
+
# an X-Inertia-Location header instead of a normal redirect, since
|
|
16
|
+
# Inertia's XHR-driven visits can't follow cross-origin redirects
|
|
17
|
+
# themselves; the client reads that header and performs a full
|
|
18
|
+
# browser visit instead.
|
|
19
|
+
#
|
|
20
|
+
# Both rewrites only apply to Inertia requests (X-Inertia header) —
|
|
21
|
+
# ordinary browser navigations are left untouched.
|
|
22
|
+
class Redirects
|
|
23
|
+
REWRITTEN_STATUSES = [301, 302].freeze
|
|
24
|
+
BODY_RESUBMITTING_METHODS = %w[PUT PATCH DELETE].freeze
|
|
25
|
+
|
|
26
|
+
def initialize(app)
|
|
27
|
+
@app = app
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(env)
|
|
31
|
+
status, headers, body = @app.call(env)
|
|
32
|
+
|
|
33
|
+
request_context = RequestContext.new(env)
|
|
34
|
+
return [status, headers, body] unless request_context.inertia?
|
|
35
|
+
|
|
36
|
+
location = headers["Location"] || headers["location"]
|
|
37
|
+
return [status, headers, body] unless redirect?(status) && location
|
|
38
|
+
|
|
39
|
+
if external?(env, location)
|
|
40
|
+
return [409, { "X-Inertia-Location" => location }, [""]]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if REWRITTEN_STATUSES.include?(status) && BODY_RESUBMITTING_METHODS.include?(env["REQUEST_METHOD"])
|
|
44
|
+
status = 303
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
[status, headers, body]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def redirect?(status)
|
|
53
|
+
status.to_i.between?(300, 399)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def external?(env, location)
|
|
57
|
+
target = URI.parse(location)
|
|
58
|
+
return false if target.host.nil?
|
|
59
|
+
|
|
60
|
+
request = Rack::Request.new(env)
|
|
61
|
+
target.scheme != request.scheme || target.host != request.host || target.port != request.port
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rack/request"
|
|
4
|
+
|
|
5
|
+
module InertiaHanami
|
|
6
|
+
module Middleware
|
|
7
|
+
# Plain Rack middleware implementing the Inertia protocol's asset
|
|
8
|
+
# versioning handshake: on a GET Inertia request whose X-Inertia-Version
|
|
9
|
+
# header doesn't match the server's configured version, respond with a
|
|
10
|
+
# 409 + X-Inertia-Location (empty body) instead of calling downstream,
|
|
11
|
+
# so the client performs a full browser visit and picks up new assets.
|
|
12
|
+
class Version
|
|
13
|
+
def initialize(app)
|
|
14
|
+
@app = app
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
request_context = RequestContext.new(env)
|
|
19
|
+
|
|
20
|
+
if stale?(env, request_context)
|
|
21
|
+
request = Rack::Request.new(env)
|
|
22
|
+
return [409, { "X-Inertia-Location" => request.url }, [""]]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@app.call(env)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def stale?(env, request_context)
|
|
31
|
+
env["REQUEST_METHOD"] == "GET" &&
|
|
32
|
+
request_context.inertia? &&
|
|
33
|
+
request_context.version != Hanami.app["inertia.config"].version
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InertiaHanami
|
|
4
|
+
# Resolves a props structure (plain values, Procs, Props::Base wrappers, and
|
|
5
|
+
# nested hashes thereof) against a Hanami action instance, so prop blocks can
|
|
6
|
+
# access the action's params/session/deps via instance_exec.
|
|
7
|
+
class PropEvaluator
|
|
8
|
+
def initialize(action)
|
|
9
|
+
@action = action
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def evaluate(props)
|
|
13
|
+
case props
|
|
14
|
+
when Hash
|
|
15
|
+
props.transform_values { |value| evaluate(value) }
|
|
16
|
+
when Props::Base
|
|
17
|
+
props.with(block: resolved_block(props.block))
|
|
18
|
+
when Proc
|
|
19
|
+
evaluate(@action.instance_exec(&props))
|
|
20
|
+
else
|
|
21
|
+
props
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def resolved_block(block)
|
|
28
|
+
value = evaluate(@action.instance_exec(&block))
|
|
29
|
+
-> { value }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module InertiaHanami
|
|
4
|
+
# Prop wrapper classes controlling how individual props are resolved and
|
|
5
|
+
# included in the Inertia response (partial reloads, deferred loading, etc).
|
|
6
|
+
module Props
|
|
7
|
+
# Base class for all prop wrappers. Built on Ruby's `Data`, so wrappers are
|
|
8
|
+
# immutable value objects with structural equality and no ActiveSupport
|
|
9
|
+
# dependency.
|
|
10
|
+
class Base < Data
|
|
11
|
+
def resolve
|
|
12
|
+
block.call
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# A prop that is only included in the response when explicitly requested
|
|
17
|
+
# by the client (via a partial reload).
|
|
18
|
+
Optional = Base.define(:block)
|
|
19
|
+
|
|
20
|
+
# A prop that is always included in the response, even during a partial
|
|
21
|
+
# reload that would otherwise exclude it.
|
|
22
|
+
Always = Base.define(:block)
|
|
23
|
+
|
|
24
|
+
# A prop that is loaded in a subsequent request after the initial page
|
|
25
|
+
# load, batched together by `group`.
|
|
26
|
+
Defer = Base.define(:group, :block) do
|
|
27
|
+
def initialize(block:, group: "default")
|
|
28
|
+
super
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A prop that is evaluated only once and cached by the client.
|
|
33
|
+
Once = Base.define(:key, :fresh, :expires_in, :block) do
|
|
34
|
+
def initialize(block:, key: nil, fresh: false, expires_in: nil)
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Calculates the expiration timestamp in milliseconds.
|
|
39
|
+
#
|
|
40
|
+
# @return [Integer, nil] the expiration time as a Unix timestamp in milliseconds, or nil if no expiration
|
|
41
|
+
def expires_at
|
|
42
|
+
return unless expires_in
|
|
43
|
+
|
|
44
|
+
((Time.now + expires_in).to_f * 1_000).to_i
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A prop whose value is merged with the existing client-side prop of the
|
|
49
|
+
# same name, instead of replacing it outright.
|
|
50
|
+
Merge = Base.define(:block, :deep_merge, :match_on) do
|
|
51
|
+
def initialize(block:, deep_merge: false, match_on: nil)
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A prop driving the client's infinite-scroll feature: merged (appended
|
|
57
|
+
# or prepended, per the X-Inertia-Infinite-Scroll-Merge-Intent header)
|
|
58
|
+
# instead of replacing the existing client-side prop, with pagination
|
|
59
|
+
# metadata surfaced via the response's `scrollProps` map.
|
|
60
|
+
Scroll = Base.define(:block, :match_on, :page_name, :previous_page, :next_page, :current_page) do
|
|
61
|
+
# rubocop:disable Metrics/ParameterLists -- one kwarg per client-visible scrollProps field
|
|
62
|
+
def initialize(block:, match_on: nil, page_name: "page", previous_page: nil, next_page: nil, current_page: nil)
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
# rubocop:enable Metrics/ParameterLists
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|