inertia_rails-camelize_props 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 54cfcff4bf70fe371ccf072b30e358ca3b172314995265be7348200af42006e7
4
+ data.tar.gz: 43ed1dae335109693b46daf79998492de2b82fef6178814e33e73899d0b14881
5
+ SHA512:
6
+ metadata.gz: 3a4981c3afa1dacfce183dc94919fa4ec174ff6a3431709c5bbddb70ca9e4cabdc41884322c498f40bb9dd2ad0110eedfdcab0b8be93cc03b483896e77324bbd
7
+ data.tar.gz: 0f42ba571549bf7e43a778c48376e3ea547bfa87625058d59b4e76cceb25e59a6f9f0307ceb31ddbc630c5c2993e924937595a1c2b998e24869e10f9d80d25c7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Takafumi ONAKA
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # InertiaRails::CamelizeProps
2
+
3
+ Write props in `snake_case` in Rails, receive them in `camelCase` in your components — without breaking merge props, infinite scroll, deferred props, or partial reloads.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "inertia_rails-camelize_props"
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ruby
14
+ InertiaRails.configure do |config|
15
+ config.camelize_props = true
16
+ end
17
+ ```
18
+
19
+ Or per controller, like any other Inertia Rails option:
20
+
21
+ ```ruby
22
+ class MediaController < ApplicationController
23
+ inertia_config(camelize_props: true)
24
+ end
25
+ ```
26
+
27
+ Then keep writing Rails as usual:
28
+
29
+ ```ruby
30
+ render inertia: "Media/Index", props: {
31
+ media_list: InertiaRails.scroll(pagy) { ... },
32
+ }
33
+ ```
34
+
35
+ and refer to props by their camelCase names on the client:
36
+
37
+ ```jsx
38
+ <InfiniteScroll data="mediaList">
39
+ ```
40
+
41
+ ## What gets camelized
42
+
43
+ ### Responses
44
+
45
+ Keys are converted:
46
+
47
+ ```ruby
48
+ props: { user_profile: { display_name: "John Doe" } }
49
+ ```
50
+
51
+ ```json
52
+ "props": { "userProfile": { "displayName": "John Doe" } }
53
+ ```
54
+
55
+ That happens at every level, once the props have been resolved, so whatever a block returned is converted like anything else. The keys of the flash are converted too.
56
+
57
+ A page also names props outside of `props`, in the metadata that tells the client what to merge, what to fetch later and what it already holds. Those names are converted to match, which is what keeps those features working — an infinite scroll over `media_list` goes out as:
58
+
59
+ ```json
60
+ {
61
+ "props": { "mediaList": [...] },
62
+ "scrollProps": { "mediaList": { "pageName": "cursor" } },
63
+ "mergeProps": ["mediaList"]
64
+ }
65
+ ```
66
+
67
+ The rest of that metadata is `prependProps`, `deepMergeProps`, `matchPropsOn`, `deferredProps`, `onceProps`, `rescuedProps` and `sharedProps`.
68
+
69
+ What is left alone names no prop: the group a deferred prop is batched under, the query parameter a scroll prop paginates with, and a `once` key you supplied yourself.
70
+
71
+ ### Requests
72
+
73
+ A frontend written in camelCase submits camelCase, so the parameters are renamed before the action runs:
74
+
75
+ ```ruby
76
+ # the request arrives as { mediaItem: { mediaId: …, createdAt: … } }
77
+ params.expect(media_item: [:media_id, :created_at])
78
+ ```
79
+
80
+ Only requests carrying `X-Inertia` are renamed.
81
+
82
+ The partial reload headers (`X-Inertia-Partial-Data`, `X-Inertia-Partial-Except`, `X-Inertia-Reset`, `X-Inertia-Except-Once-Props`) are matched against both the camelCase and the original name, so a frontend that still sends `media_list` keeps working.
83
+
84
+ ## Caveats
85
+
86
+ - This gem patches private Inertia Rails internals, so an Inertia Rails upgrade may need a new release here.
87
+ - A name with a digit straight after an underscore does not survive the round trip: `address_line_1` goes out as `addressLine1` and comes back as `address_line1`, since case cannot mark a word boundary in front of a digit. Name attributes without an underscore in front of a digit.
88
+ - Every key is a name to be converted, so do not build keys out of user input. A value that needs arbitrary keys can go as a JSON string, or as an array of objects carrying the key as a field.
89
+ - Remove any other case conversion: a key-renaming `prop_transformer`, which still runs before this one, or [inertia-caseshift](https://github.com/skryukov/inertia-caseshift) on the client.
90
+
91
+ ## The problem
92
+
93
+ Inertia Rails already has `prop_transformer`, and its documentation suggests using it to camelize keys:
94
+
95
+ ```ruby
96
+ config.prop_transformer = ->(props:) { props.deep_transform_keys { |key| key.to_s.camelize(:lower) } }
97
+ ```
98
+
99
+ This silently breaks every feature that refers to a prop *by name*. Only the props hash is transformed, so the response disagrees with itself — here, an infinite scroll over `media_list`:
100
+
101
+ ```json
102
+ {
103
+ "props": { "mediaList": [] },
104
+ "scrollProps": { "media_list": { "pageName": "cursor" } },
105
+ "mergeProps": ["media_list"]
106
+ }
107
+ ```
108
+
109
+ The client looks up `props["media_list"]` to merge into, finds nothing, and replaces the accumulated list instead of appending to it.
110
+
111
+ Prop names appear in four places, and `prop_transformer` reaches only the first:
112
+
113
+ 1. the keys of the props hash
114
+ 2. the response metadata — `mergeProps`, `scrollProps`, `deferredProps`, `onceProps`, `matchPropsOn`, … — which list prop names *as values*
115
+ 3. partial reload request headers (`X-Inertia-Partial-Data` and friends), matched server-side
116
+ 4. prop options such as `match_on:` and `append:`, which are strings on the prop object rather than keys of any hash
117
+
118
+ Moving the `prop_transformer` call to a different point does not help. A hash transform can only run *after* evaluation, since the keys inside a lazy prop's return value do not exist before it; but name matching must finish *before* evaluation, because skipping the evaluation of unrequested props is the whole point of partial reloads. Wherever the transform sits, the matching happens on the other side of it — and the `match_on:` strings are never keys of any hash, so no transform of one reaches them at all.
119
+
120
+ The shape is wrong. A `Hash -> Hash` transformer can only hand the adapter a transformed **result**, and a result is the one thing that cannot be reused: the protocol needs the **mapping** itself, a per-key function it can apply to a single name wherever that name turns up. No `Hash -> Hash` function, called at any point, can supply that.
121
+
122
+ ## Why this gem exists
123
+
124
+ Rails names things in `snake_case` and JavaScript in `camelCase`, and neither side should have to take up the other's convention to be understood. Something between them has to carry the difference, and the adapter is already there, writing the props and the protocol's names around them.
125
+
126
+ Inertia Rails offers `prop_transformer`, which the problem above rules out. What answers it is not a `Hash -> Hash` transformation but a naming rule: something the adapter holds and applies to one name at a time, wherever a name appears. The Elixir adapter already offers one, behind a [`camelize_props`](https://inertia.hexdocs.pm/readme.html) flag.
127
+
128
+ [Pull request 267](https://redirect.github.com/inertiajs/inertia-rails/pull/267) tried before, reading the problem as merge props being left out rather than as the shape of the transformer, and added a second lambda for them. Inertia Rails means to deprecate `prop_transformer` rather than improve it, which is right: a `Hash -> Hash` function is the wrong shape for this. This gem adds `camelize_props` instead.
129
+
130
+ [inertia-caseshift](https://github.com/skryukov/inertia-caseshift) answers the same question on the client, in both directions, with one implementation serving every backend. This answers it on the server instead.
131
+
132
+ ## Development
133
+
134
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt.
135
+
136
+ ## Contributing
137
+
138
+ Bug reports and pull requests are welcome on GitHub at https://github.com/onk/inertia_rails-camelize_props.
139
+
140
+ ## License
141
+
142
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaRails
4
+ module CamelizeProps
5
+ # Renames the parameters a request arrived with to the names the
6
+ # application declared, so that everything downstream — `permit`, `expect`,
7
+ # a nested form, a gem that reads `params` — sees what it expects.
8
+ #
9
+ # Only requests that are part of the Inertia conversation are renamed. The
10
+ # convention holds between this application and the frontend it handed its
11
+ # names to, and nowhere else.
12
+ module Controller
13
+ def self.install(controller = ::ActionController::Base)
14
+ controller.before_action do
15
+ next unless inertia_configuration.camelize_props && request.inertia?
16
+
17
+ params.deep_transform_keys! { |key| key.to_s.underscore }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaRails
4
+ module CamelizeProps
5
+ # The prop names a partial reload asked for, which arrive in camelCase
6
+ # because that is how the client was given them. A name is looked up in
7
+ # both forms, so that a frontend still naming props as Rails declared
8
+ # them keeps working.
9
+ class KeyList < Array
10
+ def include?(name)
11
+ super || super(Naming.path(name))
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaRails
4
+ module CamelizeProps
5
+ # Camelizes the names a page carries outside of `props`: the metadata the
6
+ # client reads to decide what to merge, defer or remember, and the flash it
7
+ # reads by key. All of them were chosen by the application and handed to
8
+ # the client, so all of them follow the same convention.
9
+ module Metadata
10
+ PATH_LISTS = %i[mergeProps prependProps deepMergeProps matchPropsOn rescuedProps sharedProps].freeze
11
+
12
+ def self.camelize(page)
13
+ page.to_h { |name, value| [name, camelize_entry(name, value)] }
14
+ end
15
+
16
+ def self.camelize_entry(name, value)
17
+ return camelize_paths(value) if PATH_LISTS.include?(name)
18
+
19
+ case name
20
+ when :deferredProps then camelize_grouped_paths(value)
21
+ when :scrollProps then camelize_keyed_paths(value)
22
+ when :onceProps then camelize_once(value)
23
+ when :flash then Naming.deep(value)
24
+ else value
25
+ end
26
+ end
27
+ private_class_method :camelize_entry
28
+
29
+ def self.camelize_paths(paths)
30
+ paths.map { |path| Naming.path(path) }
31
+ end
32
+ private_class_method :camelize_paths
33
+
34
+ # The group a deferred prop belongs to is named by the application and is
35
+ # not a prop name.
36
+ def self.camelize_grouped_paths(groups)
37
+ groups.transform_values { |paths| camelize_paths(paths) }
38
+ end
39
+ private_class_method :camelize_grouped_paths
40
+
41
+ # A scroll prop's value describes its pagination rather than any prop.
42
+ def self.camelize_keyed_paths(paths)
43
+ paths.transform_keys { |path| Naming.path(path) }
44
+ end
45
+ private_class_method :camelize_keyed_paths
46
+
47
+ # A once key only names a prop when the application did not supply one of
48
+ # its own, in which case it is an opaque identifier and stays as it is.
49
+ def self.camelize_once(once_props)
50
+ once_props.to_h do |once_key, entry|
51
+ path = entry[:prop]
52
+ [once_key.to_s == path.to_s ? Naming.path(once_key) : once_key, entry.merge(prop: Naming.path(path))]
53
+ end
54
+ end
55
+ private_class_method :camelize_once
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module InertiaRails
6
+ module CamelizeProps
7
+ # The mapping this gem owns. Every conversion goes through a single name at
8
+ # a time, so the same mapping serves prop keys, the paths in the protocol
9
+ # metadata, and the matching of names arriving in partial reload headers.
10
+ module Naming
11
+ # Preserves the String/Symbol type of the original, so that keys Inertia
12
+ # Rails looks up by symbol keep matching after conversion.
13
+ def self.key(name)
14
+ camelized = name.to_s.camelize(:lower)
15
+ name.is_a?(Symbol) ? camelized.to_sym : camelized
16
+ end
17
+
18
+ # Prop paths are dot separated ("user.first_name"), and each segment is a
19
+ # name in its own right. Array indices camelize to themselves.
20
+ def self.path(path)
21
+ path.to_s.split(".").map { |segment| segment.camelize(:lower) }.join(".")
22
+ end
23
+
24
+ def self.deep(value)
25
+ case value
26
+ when Hash
27
+ value.each_with_object({}) { |(name, nested), result| result[key(name)] = deep(nested) }
28
+ when Array
29
+ value.map { |item| deep(item) }
30
+ else
31
+ value
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inertia_rails"
4
+
5
+ module InertiaRails
6
+ module CamelizeProps
7
+ # Registers `camelize_props` as an Inertia Rails option, so that it can be
8
+ # set globally or per controller like any other one.
9
+ #
10
+ # Inertia Rails builds its option list into frozen constants as it loads,
11
+ # and rejects any option it does not recognize, so adding one means
12
+ # replacing those constants rather than adding to them.
13
+ module Option
14
+ NAME = :camelize_props
15
+
16
+ def self.install(configuration = ::InertiaRails::Configuration)
17
+ return if configuration::OPTION_NAMES.include?(NAME)
18
+
19
+ defaults = configuration::DEFAULTS.merge(NAME => false).freeze
20
+ replace_constant(configuration, :DEFAULTS, defaults)
21
+ replace_constant(configuration, :OPTION_NAMES, defaults.keys.freeze)
22
+ define_accessors(configuration)
23
+ end
24
+
25
+ def self.replace_constant(configuration, name, value)
26
+ configuration.send(:remove_const, name)
27
+ configuration.const_set(name, value)
28
+ end
29
+ private_class_method :replace_constant
30
+
31
+ # Inertia Rails defines an accessor for each option it knows about while
32
+ # it loads, which has already happened by now.
33
+ def self.define_accessors(configuration)
34
+ configuration.define_method(NAME) { evaluate_option(options[NAME]) }
35
+ configuration.define_method(:"#{NAME}=") { |value| @options[NAME] = value }
36
+ end
37
+ private_class_method :define_accessors
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inertia_rails"
4
+
5
+ module InertiaRails
6
+ module CamelizeProps
7
+ # Matches the names a partial reload asked for against the props. The
8
+ # requested names are in camelCase, the paths here are not, so each path is
9
+ # offered in both forms and Inertia Rails' own matching decides.
10
+ module PropsResolver
11
+ def self.install(resolver = ::InertiaRails::PropsResolver)
12
+ resolver.prepend(self)
13
+ end
14
+
15
+ private
16
+
17
+ def explicitly_requested?(path)
18
+ candidates(path).any? { |candidate| super(candidate) }
19
+ end
20
+
21
+ # A prop survives an `only` filter as soon as one of its names was asked
22
+ # for, so it is excluded only when every one of them was left out.
23
+ def excluded_by_only_partial_keys?(path)
24
+ candidates(path).all? { |candidate| super(candidate) }
25
+ end
26
+
27
+ def excluded_by_except_partial_keys?(path)
28
+ candidates(path).any? { |candidate| super(candidate) }
29
+ end
30
+
31
+ def candidates(path)
32
+ return [path] unless partial_keys.is_a?(KeyList)
33
+
34
+ [path, Naming.path(path)].uniq
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "inertia_rails"
4
+
5
+ module InertiaRails
6
+ module CamelizeProps
7
+ # Camelizes the page once the renderer has finished building it. Every way a
8
+ # page leaves Rails is covered from here, because the response body, the
9
+ # `data-page` of the initial HTML and the payload posted to the SSR server
10
+ # are all built from this one hash.
11
+ module Renderer
12
+ def self.install(renderer = ::InertiaRails::Renderer)
13
+ renderer.prepend(self)
14
+ end
15
+
16
+ private
17
+
18
+ def page
19
+ page = super
20
+ return page unless @configuration.camelize_props
21
+ return page if @camelized
22
+
23
+ @camelized = true
24
+ @page = Metadata.camelize(page).merge(props: camelize_page_props(page[:props]))
25
+ end
26
+
27
+ # The names a partial reload asked for are matched against the props by
28
+ # the resolver, which recognizes this list as holding client-side names.
29
+ def parse_header(name)
30
+ keys = super
31
+ return keys unless @configuration.camelize_props
32
+
33
+ KeyList.new(keys || [])
34
+ end
35
+
36
+ # The meta prop carries serialized meta tags rather than props, under a
37
+ # name Inertia Rails and the client both address directly.
38
+ def camelize_page_props(props)
39
+ Naming.deep(props.except(:_inertia_meta)).merge(props.slice(:_inertia_meta))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module InertiaRails
4
+ module CamelizeProps
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "camelize_props/version"
4
+ require_relative "camelize_props/naming"
5
+ require_relative "camelize_props/key_list"
6
+ require_relative "camelize_props/metadata"
7
+ require_relative "camelize_props/option"
8
+ require_relative "camelize_props/controller"
9
+ require_relative "camelize_props/props_resolver"
10
+ require_relative "camelize_props/renderer"
11
+
12
+ InertiaRails::CamelizeProps::Option.install
13
+ ActiveSupport.on_load(:action_controller_base) do
14
+ InertiaRails::CamelizeProps::Controller.install(self)
15
+ end
16
+
17
+ InertiaRails::CamelizeProps::PropsResolver.install
18
+ InertiaRails::CamelizeProps::Renderer.install
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: inertia_rails-camelize_props
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Takafumi ONAKA
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: inertia_rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 3.22.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 3.22.0
26
+ description: Camelizes prop names on the server, including Inertia's protocol metadata
27
+ and partial reload headers.
28
+ email:
29
+ - takafumi.onaka@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - lib/inertia_rails/camelize_props.rb
38
+ - lib/inertia_rails/camelize_props/controller.rb
39
+ - lib/inertia_rails/camelize_props/key_list.rb
40
+ - lib/inertia_rails/camelize_props/metadata.rb
41
+ - lib/inertia_rails/camelize_props/naming.rb
42
+ - lib/inertia_rails/camelize_props/option.rb
43
+ - lib/inertia_rails/camelize_props/props_resolver.rb
44
+ - lib/inertia_rails/camelize_props/renderer.rb
45
+ - lib/inertia_rails/camelize_props/version.rb
46
+ homepage: https://github.com/onk/inertia_rails-camelize_props
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ allowed_push_host: https://rubygems.org
51
+ homepage_uri: https://github.com/onk/inertia_rails-camelize_props
52
+ source_code_uri: https://github.com/onk/inertia_rails-camelize_props
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 3.4.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.9
69
+ specification_version: 4
70
+ summary: Server-side snake_case to camelCase prop conversion for Inertia Rails.
71
+ test_files: []