inertiax_rails 2.0.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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/push.yml +33 -0
  3. data/.gitignore +22 -0
  4. data/.rspec +3 -0
  5. data/CHANGELOG.md +173 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/Gemfile +7 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +273 -0
  10. data/Rakefile +6 -0
  11. data/app/controllers/inertia_rails/static_controller.rb +7 -0
  12. data/app/views/inertia.html.erb +1 -0
  13. data/app/views/inertia_ssr.html.erb +1 -0
  14. data/bin/console +14 -0
  15. data/bin/setup +8 -0
  16. data/inertiax_rails.gemspec +37 -0
  17. data/lib/generators/inertia_rails/install/controller.rb +7 -0
  18. data/lib/generators/inertia_rails/install/react/InertiaExample.jsx +9 -0
  19. data/lib/generators/inertia_rails/install/react/inertia.jsx +17 -0
  20. data/lib/generators/inertia_rails/install/svelte/InertiaExample.svelte +11 -0
  21. data/lib/generators/inertia_rails/install/svelte/inertia.js +14 -0
  22. data/lib/generators/inertia_rails/install/vue/InertiaExample.vue +11 -0
  23. data/lib/generators/inertia_rails/install/vue/inertia.js +20 -0
  24. data/lib/generators/inertia_rails/install_generator.rb +84 -0
  25. data/lib/inertia_rails/controller.rb +110 -0
  26. data/lib/inertia_rails/engine.rb +16 -0
  27. data/lib/inertia_rails/inertia_rails.rb +52 -0
  28. data/lib/inertia_rails/lazy.rb +28 -0
  29. data/lib/inertia_rails/middleware.rb +97 -0
  30. data/lib/inertia_rails/renderer.rb +92 -0
  31. data/lib/inertia_rails/rspec.rb +125 -0
  32. data/lib/inertia_rails/version.rb +3 -0
  33. data/lib/inertia_rails.rb +24 -0
  34. data/lib/patches/better_errors.rb +17 -0
  35. data/lib/patches/debug_exceptions/patch-5-0.rb +23 -0
  36. data/lib/patches/debug_exceptions/patch-5-1.rb +26 -0
  37. data/lib/patches/debug_exceptions.rb +17 -0
  38. data/lib/patches/mapper.rb +8 -0
  39. data/lib/patches/request.rb +9 -0
  40. data/lib/tasks/inertia_rails.rake +16 -0
  41. metadata +203 -0
@@ -0,0 +1,37 @@
1
+ lib = File.expand_path("lib", __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "inertia_rails/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "inertiax_rails"
7
+ spec.version = InertiaRails::VERSION
8
+ spec.authors = ["Brian Knoles", "Brandon Shar", "Eugene Granovsky", "Stefan Buhrmester"]
9
+ spec.email = ["brain@bellawatt.com", "brandon@bellawatt.com", "eugene@bellawatt.com", "stefan@buhrmi.de"]
10
+
11
+ spec.summary = %q{Inertia adapter for Rails}
12
+ spec.homepage = "https://github.com/buhrmi/inertia-rails"
13
+ spec.license = "MIT"
14
+
15
+ spec.metadata["homepage_uri"] = spec.homepage
16
+ spec.metadata["source_code_uri"] = spec.homepage
17
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md"
18
+
19
+ # Specify which files should be added to the gem when it is released.
20
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
21
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
22
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
23
+ end
24
+ spec.bindir = "exe"
25
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.require_paths = ["lib"]
27
+
28
+ spec.add_runtime_dependency "railties", '>= 5'
29
+
30
+ spec.add_development_dependency "bundler", "~> 2.0"
31
+ spec.add_development_dependency "rake", "~> 13.0"
32
+ spec.add_development_dependency "rspec-rails", "~> 4.0"
33
+ spec.add_development_dependency "rails-controller-testing"
34
+ spec.add_development_dependency "sqlite3"
35
+ spec.add_development_dependency "responders"
36
+ spec.add_development_dependency "debug"
37
+ end
@@ -0,0 +1,7 @@
1
+ class InertiaExampleController < ApplicationController
2
+ def index
3
+ render inertia: 'InertiaExample', props: {
4
+ name: 'World',
5
+ }
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+
3
+ const InertiaExample = ({name}) => (
4
+ <>
5
+ <h1>Hello {name}!</h1>
6
+ </>
7
+ );
8
+
9
+ export default InertiaExample;
@@ -0,0 +1,17 @@
1
+ import { App } from '@inertiajs/inertia-react';
2
+ import React from 'react';
3
+ import { render } from 'react-dom';
4
+ import { InertiaProgress } from '@inertiajs/progress';
5
+
6
+ document.addEventListener('DOMContentLoaded', () => {
7
+ InertiaProgress.init();
8
+ const el = document.getElementById('app')
9
+
10
+ render(
11
+ <App
12
+ initialPage={JSON.parse(el.dataset.page)}
13
+ resolveComponent={name => require(`../Pages/${name}`).default}
14
+ />,
15
+ el
16
+ )
17
+ });
@@ -0,0 +1,11 @@
1
+ <script>
2
+ export let name;
3
+ </script>
4
+
5
+ <style>
6
+ h1 { font-family: sans-serif; text-align: center; }
7
+ </style>
8
+
9
+ <h1>
10
+ Hello {name}!
11
+ </h1>
@@ -0,0 +1,14 @@
1
+ import { createInertiaApp } from '@inertiajs/inertia-svelte'
2
+ import { InertiaProgress } from '@inertiajs/progress'
3
+
4
+ document.addEventListener('DOMContentLoaded', () => {
5
+ InertiaProgress.init()
6
+
7
+ createInertiaApp({
8
+ id: 'app',
9
+ resolve: name => import(`../Pages/${name}.svelte`),
10
+ setup({ el, App, props }) {
11
+ new App({ target: el, props })
12
+ },
13
+ })
14
+ })
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <h1 :style="{ 'text-align': 'center' }">Hello {{name}}!</h1>
3
+ </template>
4
+
5
+ <script>
6
+ export default {
7
+ props: {
8
+ name: { type: String, required: true },
9
+ },
10
+ }
11
+ </script>
@@ -0,0 +1,20 @@
1
+ import Vue from 'vue'
2
+
3
+ import { app, plugin } from '@inertiajs/inertia-vue'
4
+ import { InertiaProgress } from '@inertiajs/progress'
5
+
6
+ document.addEventListener('DOMContentLoaded', () => {
7
+ InertiaProgress.init();
8
+ const el = document.getElementById('app')
9
+
10
+ Vue.use(plugin)
11
+
12
+ new Vue({
13
+ render: h => h(app, {
14
+ props: {
15
+ initialPage: JSON.parse(el.dataset.page),
16
+ resolveComponent: name => require(`../Pages/${name}`).default,
17
+ },
18
+ }),
19
+ }).$mount(el)
20
+ })
@@ -0,0 +1,84 @@
1
+ module InertiaRails
2
+ class InstallGenerator < Rails::Generators::Base
3
+ source_root File.expand_path('./install', __dir__)
4
+ class_option :front_end, type: :string, default: 'react'
5
+
6
+ FRONT_END_INSTALLERS = [
7
+ 'react',
8
+ 'vue',
9
+ 'svelte',
10
+ ]
11
+
12
+ def install
13
+ exit! unless installable?
14
+
15
+ install_base!
16
+
17
+ send "install_#{options[:front_end]}!"
18
+
19
+ say "You're all set! Run rails s and checkout localhost:3000/inertia-example", :green
20
+ end
21
+
22
+ protected
23
+
24
+ def installable?
25
+ unless run("./bin/rails webpacker:verify_install")
26
+ say "Sorry, you need to have webpacker installed for inertia_rails default setup.", :red
27
+ return false
28
+ end
29
+
30
+ unless options[:front_end].in? FRONT_END_INSTALLERS
31
+ say "Sorry, there is no generator for #{options[:front_end]}!\n\n", :red
32
+ say "If you are a #{options[:front_end]} developer, please help us improve inertia_rails by contributing an installer.\n\n"
33
+ say "https://github.com/inertiajs/inertia-rails/\n\n"
34
+
35
+ return false
36
+ end
37
+
38
+ true
39
+ end
40
+
41
+ def install_base!
42
+ say "Adding inertia pack tag to application layout", :blue
43
+ insert_into_file Rails.root.join("app/views/layouts/application.html.erb").to_s, after: "<%= javascript_pack_tag 'application' %>\n" do
44
+ "\t\t<%= javascript_pack_tag 'inertia' %>\n"
45
+ end
46
+
47
+ say "Installing inertia client packages", :blue
48
+ run "yarn add @inertiajs/inertia @inertiajs/progress"
49
+
50
+ say "Copying example files", :blue
51
+ template "controller.rb", Rails.root.join("app/controllers/inertia_example_controller.rb").to_s
52
+
53
+ say "Adding a route for the example inertia controller...", :blue
54
+ route "get 'inertia-example', to: 'inertia_example#index'"
55
+ end
56
+
57
+ def install_react!
58
+ say "Creating a React page component...", :blue
59
+ run 'yarn add @inertiajs/inertia-react'
60
+ template "react/InertiaExample.jsx", Rails.root.join("app/javascript/Pages/InertiaExample.js").to_s
61
+ say "Copying inertia.jsx into webpacker's packs folder...", :blue
62
+ template "react/inertia.jsx", Rails.root.join("app/javascript/packs/inertia.jsx").to_s
63
+ say "done!", :green
64
+ end
65
+
66
+ def install_vue!
67
+ say "Creating a Vue page component...", :blue
68
+ run 'yarn add @inertiajs/inertia-vue'
69
+ template "vue/InertiaExample.vue", Rails.root.join("app/javascript/Pages/InertiaExample.vue").to_s
70
+ say "Copying inertia.js into webpacker's packs folder...", :blue
71
+ template "vue/inertia.js", Rails.root.join("app/javascript/packs/inertia.js").to_s
72
+ say "done!", :green
73
+ end
74
+
75
+ def install_svelte!
76
+ say "Creating a Svelte page component...", :blue
77
+ run 'yarn add @inertiajs/inertia-svelte'
78
+ template "svelte/InertiaExample.svelte", Rails.root.join("app/javascript/Pages/InertiaExample.svelte").to_s
79
+ say "Copying inertia.js into webpacker's packs folder...", :blue
80
+ template "svelte/inertia.js", Rails.root.join("app/javascript/packs/inertia.js").to_s
81
+ say "done!", :green
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,110 @@
1
+ require_relative "inertia_rails"
2
+
3
+ module InertiaRails
4
+ module Controller
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ helper_method :inertia_headers
9
+
10
+ before_action do
11
+ error_sharing = proc do
12
+ # :inertia_errors are deleted from the session by the middleware
13
+ if @_request && session[:inertia_errors].present?
14
+ { errors: session[:inertia_errors] }
15
+ else
16
+ {}
17
+ end
18
+ end
19
+
20
+ @_inertia_shared_plain_data ||= {}
21
+ @_inertia_shared_blocks ||= [error_sharing]
22
+ @_inertia_html_headers ||= []
23
+ end
24
+
25
+ after_action do
26
+ cookies['XSRF-TOKEN'] = form_authenticity_token unless !protect_against_forgery?
27
+ end
28
+ end
29
+
30
+ module ClassMethods
31
+ def inertia_share(hash = nil, &block)
32
+ before_action do
33
+ @_inertia_shared_plain_data = @_inertia_shared_plain_data.merge(hash) if hash
34
+ @_inertia_shared_blocks = @_inertia_shared_blocks + [block] if block_given?
35
+ end
36
+ end
37
+
38
+ def use_inertia_instance_props
39
+ before_action do
40
+ @_inertia_instance_props = true
41
+ @_inertia_skip_props = view_assigns.keys + ['_inertia_skip_props']
42
+ end
43
+ end
44
+ end
45
+
46
+ def inertia_headers
47
+ @_inertia_html_headers.join.html_safe
48
+ end
49
+
50
+ def inertia_headers=(value)
51
+ @_inertia_html_headers = value
52
+ end
53
+
54
+ def default_render
55
+ if InertiaRails.default_render?
56
+ render(inertia: true)
57
+ else
58
+ super
59
+ end
60
+ end
61
+
62
+ def shared_data
63
+ (@_inertia_shared_plain_data || {}).merge(evaluated_blocks)
64
+ end
65
+
66
+ def redirect_to(options = {}, response_options = {})
67
+ capture_inertia_errors(response_options)
68
+ super(options, response_options)
69
+ end
70
+
71
+ def redirect_back(fallback_location:, allow_other_host: true, **options)
72
+ capture_inertia_errors(options)
73
+ super(
74
+ fallback_location: fallback_location,
75
+ allow_other_host: allow_other_host,
76
+ **options,
77
+ )
78
+ end
79
+
80
+ def inertia_view_assigns
81
+ return {} unless @_inertia_instance_props
82
+ view_assigns.except(*@_inertia_skip_props)
83
+ end
84
+
85
+ private
86
+
87
+ def inertia_layout
88
+ layout = ::InertiaRails.layout
89
+
90
+ # When the global configuration is not set, let Rails decide which layout
91
+ # should be used based on the controller configuration.
92
+ layout.nil? ? true : layout
93
+ end
94
+
95
+ def inertia_location(url)
96
+ headers['X-Inertia-Location'] = url
97
+ head :conflict
98
+ end
99
+
100
+ def capture_inertia_errors(options)
101
+ if (inertia_errors = options.dig(:inertia, :errors))
102
+ session[:inertia_errors] = inertia_errors
103
+ end
104
+ end
105
+
106
+ def evaluated_blocks
107
+ (@_inertia_shared_blocks || []).map { |block| instance_exec(&block) }.reduce(&:merge) || {}
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,16 @@
1
+ require_relative "middleware"
2
+ require_relative "controller"
3
+
4
+ module InertiaRails
5
+ class Engine < ::Rails::Engine
6
+ initializer "inertia_rails.configure_rails_initialization" do |app|
7
+ app.middleware.use ::InertiaRails::Middleware
8
+ end
9
+
10
+ initializer "inertia_rails.action_controller" do
11
+ ActiveSupport.on_load(:action_controller_base) do
12
+ include ::InertiaRails::Controller
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,52 @@
1
+ # Needed for `thread_mattr_accessor`
2
+ require 'active_support/core_ext/module/attribute_accessors_per_thread'
3
+ require 'inertia_rails/lazy'
4
+
5
+ module InertiaRails
6
+ def self.configure
7
+ yield(Configuration)
8
+ end
9
+
10
+ def self.version
11
+ Configuration.evaluated_version
12
+ end
13
+
14
+ def self.layout
15
+ Configuration.layout
16
+ end
17
+
18
+ def self.ssr_enabled?
19
+ Configuration.ssr_enabled
20
+ end
21
+
22
+ def self.ssr_url
23
+ Configuration.ssr_url
24
+ end
25
+
26
+ def self.default_render?
27
+ Configuration.default_render
28
+ end
29
+
30
+ def self.deep_merge_shared_data?
31
+ Configuration.deep_merge_shared_data
32
+ end
33
+
34
+ def self.lazy(value = nil, &block)
35
+ InertiaRails::Lazy.new(value, &block)
36
+ end
37
+
38
+ private
39
+
40
+ module Configuration
41
+ mattr_accessor(:layout) { nil }
42
+ mattr_accessor(:version) { nil }
43
+ mattr_accessor(:ssr_enabled) { false }
44
+ mattr_accessor(:ssr_url) { 'http://localhost:13714' }
45
+ mattr_accessor(:default_render) { false }
46
+ mattr_accessor(:deep_merge_shared_data) { false }
47
+
48
+ def self.evaluated_version
49
+ self.version.respond_to?(:call) ? self.version.call : self.version
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,28 @@
1
+ module InertiaRails
2
+ class Lazy
3
+ def initialize(value = nil, &block)
4
+ @value = value
5
+ @block = block
6
+ end
7
+
8
+ def call
9
+ to_proc.call
10
+ end
11
+
12
+ def to_proc
13
+ # This is called by controller.instance_exec, which changes self to the
14
+ # controller instance. That makes the instance variables unavailable to the
15
+ # proc via closure. Copying the instance variables to local variables before
16
+ # the proc is returned keeps them in scope for the returned proc.
17
+ value = @value
18
+ block = @block
19
+ if value.respond_to?(:call)
20
+ value
21
+ elsif value
22
+ Proc.new { value }
23
+ else
24
+ block
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ module InertiaRails
2
+ class Middleware
3
+ def initialize(app)
4
+ @app = app
5
+ end
6
+
7
+ def call(env)
8
+ InertiaRailsRequest.new(@app, env)
9
+ .response
10
+ end
11
+
12
+ class InertiaRailsRequest
13
+ def initialize(app, env)
14
+ @app = app
15
+ @env = env
16
+ end
17
+
18
+ def response
19
+ copy_xsrf_to_csrf!
20
+ status, headers, body = @app.call(@env)
21
+ request = ActionDispatch::Request.new(@env)
22
+
23
+ # Inertia errors are added to the session via redirect_to
24
+ request.session.delete(:inertia_errors) unless keep_inertia_errors?(status)
25
+
26
+ status = 303 if inertia_non_post_redirect?(status)
27
+
28
+ stale_inertia_get? ? force_refresh(request) : [status, headers, body]
29
+ end
30
+
31
+ private
32
+
33
+ def keep_inertia_errors?(status)
34
+ redirect_status?(status) || stale_inertia_request?
35
+ end
36
+
37
+ def stale_inertia_request?
38
+ inertia_request? && version_stale?
39
+ end
40
+
41
+ def redirect_status?(status)
42
+ [301, 302].include? status
43
+ end
44
+
45
+ def non_get_redirectable_method?
46
+ ['PUT', 'PATCH', 'DELETE'].include? request_method
47
+ end
48
+
49
+ def inertia_non_post_redirect?(status)
50
+ inertia_request? && redirect_status?(status) && non_get_redirectable_method?
51
+ end
52
+
53
+ def stale_inertia_get?
54
+ get? && stale_inertia_request?
55
+ end
56
+
57
+ def get?
58
+ request_method == 'GET'
59
+ end
60
+
61
+ def request_method
62
+ @env['REQUEST_METHOD']
63
+ end
64
+
65
+ def inertia_version
66
+ @env['HTTP_X_INERTIA_VERSION']
67
+ end
68
+
69
+ def inertia_request?
70
+ @env['HTTP_X_INERTIA'].present?
71
+ end
72
+
73
+ def version_stale?
74
+ sent_version != saved_version
75
+ end
76
+
77
+ def sent_version
78
+ return nil if inertia_version.nil?
79
+
80
+ InertiaRails.version.is_a?(Numeric) ? inertia_version.to_f : inertia_version
81
+ end
82
+
83
+ def saved_version
84
+ InertiaRails.version.is_a?(Numeric) ? InertiaRails.version.to_f : InertiaRails.version
85
+ end
86
+
87
+ def force_refresh(request)
88
+ request.flash.keep
89
+ Rack::Response.new('', 409, {'X-Inertia-Location' => request.original_url}).finish
90
+ end
91
+
92
+ def copy_xsrf_to_csrf!
93
+ @env['HTTP_X_CSRF_TOKEN'] = @env['HTTP_X_XSRF_TOKEN'] if @env['HTTP_X_XSRF_TOKEN'] && inertia_request?
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,92 @@
1
+ require_relative "inertia_rails"
2
+
3
+ module InertiaRails
4
+ class Renderer
5
+ attr_reader :component, :view_data
6
+
7
+ def initialize(component, controller, request, response, render_method, props: nil, view_data: nil, deep_merge: nil)
8
+ @component = component.is_a?(TrueClass) ? "#{controller.controller_path}/#{controller.action_name}" : component
9
+ @controller = controller
10
+ @request = request
11
+ @response = response
12
+ @render_method = render_method
13
+ @props = props ? props : controller.inertia_view_assigns
14
+ @view_data = view_data || {}
15
+ @deep_merge = !deep_merge.nil? ? deep_merge : InertiaRails.deep_merge_shared_data?
16
+ end
17
+
18
+ def render
19
+ if @request.headers['X-Inertia']
20
+ @response.set_header('Vary', 'X-Inertia')
21
+ @response.set_header('X-Inertia', 'true')
22
+ @render_method.call json: page, status: @response.status, content_type: Mime[:json]
23
+ else
24
+ return render_ssr if ::InertiaRails.ssr_enabled? rescue nil
25
+ @render_method.call template: 'inertia', layout: layout, locals: (view_data).merge({page: page})
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def render_ssr
32
+ uri = URI("#{::InertiaRails.ssr_url}/render")
33
+ res = JSON.parse(Net::HTTP.post(uri, page.to_json, 'Content-Type' => 'application/json').body)
34
+
35
+ @controller.inertia_headers = res['head']
36
+ @render_method.call template: 'inertia_ssr', layout: layout, locals: (view_data).merge({page: page, html: res['body'].html_safe,})
37
+ end
38
+
39
+ def layout
40
+ @controller.send(:inertia_layout)
41
+ end
42
+
43
+ def computed_props
44
+ # Cast props to symbol keyed hash before merging so that we have a consistent data structure and
45
+ # avoid duplicate keys after merging.
46
+ #
47
+ # Functionally, this permits using either string or symbol keys in the controller. Since the results
48
+ # is cast to json, we should treat string/symbol keys as identical.
49
+ _props = @controller.shared_data.merge.deep_symbolize_keys.send(prop_merge_method, @props.deep_symbolize_keys).select do |key, prop|
50
+ if rendering_partial_component?
51
+ key.in? partial_keys
52
+ else
53
+ !prop.is_a?(InertiaRails::Lazy)
54
+ end
55
+ end
56
+
57
+ deep_transform_values(
58
+ _props,
59
+ lambda do |prop|
60
+ prop.respond_to?(:call) ? @controller.instance_exec(&prop) : prop
61
+ end
62
+ )
63
+ end
64
+
65
+ def page
66
+ {
67
+ component: component,
68
+ props: computed_props,
69
+ url: @request.original_fullpath,
70
+ version: ::InertiaRails.version,
71
+ }
72
+ end
73
+
74
+ def deep_transform_values(hash, proc)
75
+ return proc.call(hash) unless hash.is_a? Hash
76
+
77
+ hash.transform_values {|value| deep_transform_values(value, proc)}
78
+ end
79
+
80
+ def partial_keys
81
+ (@request.headers['X-Inertia-Partial-Data'] || '').split(',').compact.map(&:to_sym)
82
+ end
83
+
84
+ def rendering_partial_component?
85
+ @request.inertia_partial? && @request.headers['X-Inertia-Partial-Component'] == component
86
+ end
87
+
88
+ def prop_merge_method
89
+ @deep_merge ? :deep_merge : :merge
90
+ end
91
+ end
92
+ end