inertiax_rails 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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