tson-rails 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: ab9797aad5299284b15b608b94cefe990caabe0d52ff23bff87c900c6d659aab
4
+ data.tar.gz: f7708ca210d62d67026e5d3dd17a0be76af10d696854093c431bd7fbabf10bcf
5
+ SHA512:
6
+ metadata.gz: f789a1dc7c86cfd3899b627f9739d28fedd749398ebd73cb63d2bada7b872664bac7926dea8e517e8ad5dd5a96bf58692400cc2e46842fd484195629593f1956
7
+ data.tar.gz: 46f449190bbd53c75c6bc36dc82342ca710b772217e0ffa305ab08b0b1c4a365b7999af519cf8ebf35fa80805bdcf01d0cd56027b05d54ea713bb493f1182583
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lef237
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # tson-rails
2
+
3
+ `tson-rails` renders Rails responses in the text format of [TSON](https://github.com/litterat/tson-io) (Typed Schema Object Notation).
4
+ It implements the schemaless data layer of the TSON 2026 revision 32 Text Data Format that is needed for Rails data output.
5
+
6
+ ## Installation
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "tson-rails"
11
+ ```
12
+
13
+ After restarting the application, the gem registers `application/tson` and the `.tn1` extension, and `render tson:` becomes available.
14
+
15
+ ## Rendering TSON from Rails
16
+
17
+ ```ruby
18
+ class UsersController < ApplicationController
19
+ def index
20
+ render tson: User.order(:id), pretty: true
21
+ end
22
+ end
23
+ ```
24
+
25
+ Example output:
26
+
27
+ ```tson
28
+ [
29
+ {
30
+ id: 1
31
+ name: Ada
32
+ created_at: !datetime "2026-08-09T12:34:56.000000000+09:00"
33
+ }
34
+ ]
35
+ ```
36
+
37
+ The renderer accepts the same Active Model serialization options as `render json:`.
38
+
39
+ ```ruby
40
+ render tson: @user, only: %i[id name], pretty: true
41
+ ```
42
+
43
+ It also supports `respond_to` and MIME negotiation.
44
+
45
+ ```ruby
46
+ respond_to do |format|
47
+ format.tson { render tson: User.all }
48
+ format.json { render json: User.all }
49
+ end
50
+ ```
51
+
52
+ The response Content-Type is `application/tson; charset=utf-8`.
53
+
54
+ ## Ruby API
55
+
56
+ The encoder can also be used without Rails.
57
+
58
+ ```ruby
59
+ require "tson_rails"
60
+
61
+ TsonRails.encode(
62
+ id: 1,
63
+ name: "Ada",
64
+ active: true,
65
+ tags: ["ruby", "rails"]
66
+ )
67
+ # => "{id:1,name:Ada,active:true,tags:[ruby,rails]}"
68
+ ```
69
+
70
+ Encoding rules:
71
+
72
+ - Hashes with String or Symbol keys are emitted as TSON records (`{ key: value }`).
73
+ - Hashes with other key types are emitted as TSON maps (`{ key => value }`).
74
+ - Arrays are emitted as TSON arrays (`[value value]`); compact output uses commas between values.
75
+ - `nil` becomes `null`, Ruby dates and times become `!date` or `!datetime`, and `BigDecimal` values remain numeric.
76
+ - `TsonRails.absent` becomes the TSON absent sentinel `_`, which is distinct from `nil`.
77
+ - Objects that provide `serializable_hash` (including Active Record and Active Model objects) are materialized and encoded recursively.
78
+
79
+ ```ruby
80
+ TsonRails.encode({ id: 1, note: TsonRails.absent })
81
+ # => "{id:1,note:_}"
82
+
83
+ TsonRails.encode({ id: 1, note: TsonRails.absent }, omit_absent: true)
84
+ # => "{id:1}"
85
+ ```
86
+
87
+ Output is compact by default. Pass `pretty: true` to enable newlines and indentation. To explicitly encode hashes as maps, pass `hash_mode: :map`.
88
+
89
+ ## Scope
90
+
91
+ This initial release is a TSON schemaless data-format encoder. It does not yet include TSON schema document loading, schema hash verification, automatic schema generation from type definitions, or a TSON parser. The specification is still a working draft, so compatibility should be reviewed when it is finalized as version 1.
92
+
93
+ ## Development
94
+
95
+ ```sh
96
+ bundle install
97
+ bundle exec rake test
98
+ ```
99
+
100
+ The implementation and tests are released under the MIT License.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rake/testtask"
5
+
6
+ Rake::TestTask.new do |task|
7
+ task.libs << "lib"
8
+ task.libs << "test"
9
+ task.pattern = "test/**/*_test.rb"
10
+ task.verbose = true
11
+ end
12
+
13
+ task default: :test
data/lib/tson-rails.rb ADDED
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tson_rails"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsonRails
4
+ # A value that is present in a container but intentionally has no value.
5
+ # This is distinct from nil, which is encoded as the TSON null value.
6
+ class Absent
7
+ def inspect
8
+ "TsonRails.absent"
9
+ end
10
+
11
+ alias_method :to_s, :inspect
12
+
13
+ private
14
+
15
+ def initialize
16
+ end
17
+ end
18
+
19
+ ABSENT = Absent.new.freeze
20
+
21
+ def self.absent
22
+ ABSENT
23
+ end
24
+ end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module TsonRails
8
+ # Encodes Ruby values into the TSON text data format.
9
+ #
10
+ # The encoder intentionally targets the schemaless data-format layer. It
11
+ # emits records for string/symbol-keyed hashes, maps for other hashes, and
12
+ # built-in TSON annotations for Ruby temporal and exact numeric values.
13
+ class Encoder
14
+ SERIALIZATION_OPTION_KEYS = %i[only except methods include].freeze
15
+
16
+ SAFE_UNQUOTED = /\A[A-Za-z0-9_+.\-]+\z/.freeze
17
+ DECIMAL_DIGITS = "[0-9](?:_?[0-9])*"
18
+ DECIMAL_INTEGER = "(?:0|[1-9](?:_?[0-9])*)"
19
+ BASED_INTEGER = "(?:0[xX][0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0[oO][0-7](?:_?[0-7])*|0[bB][01](?:_?[01])*)"
20
+ EXPONENT = "[eE][+-]?#{DECIMAL_DIGITS}"
21
+ FLOAT = "(?:(?:#{DECIMAL_DIGITS})?\.(?:#{DECIMAL_DIGITS})(?:#{EXPONENT})?|#{DECIMAL_DIGITS}#{EXPONENT})"
22
+ NUMBER_TOKEN = /\A[+-]?(?:#{DECIMAL_INTEGER}|#{BASED_INTEGER}|#{FLOAT})\z/.freeze
23
+ SPECIAL_FLOAT_TOKEN = /\A[+-]?\.(?:inf|infinity|nan)\z/.freeze
24
+
25
+ def initialize(options = {})
26
+ options = options.to_h
27
+
28
+ @pretty = !!options.fetch(:pretty, false)
29
+ @indent = options.fetch(:indent, " ").to_s
30
+ @omit_absent = !!options.fetch(:omit_absent, false)
31
+ @hash_mode = (options[:hash_mode] || options[:map_mode] || :auto).to_sym
32
+ @time_precision = Integer(options.fetch(:time_precision, 9))
33
+ @serialization_options = serialization_options_for(options)
34
+ @active_path = {}
35
+
36
+ unless %i[auto record map].include?(@hash_mode)
37
+ raise ArgumentError, "hash_mode must be :auto, :record, or :map"
38
+ end
39
+
40
+ if @time_precision.negative? || @time_precision > 9
41
+ raise ArgumentError, "time_precision must be between 0 and 9"
42
+ end
43
+ end
44
+
45
+ def encode(value)
46
+ encode_value(value, 0)
47
+ end
48
+
49
+ private
50
+
51
+ def serialization_options_for(options)
52
+ explicit = options[:serialization_options]
53
+ return explicit.to_h unless explicit.nil?
54
+
55
+ options.each_with_object({}) do |(key, value), result|
56
+ result[key] = value if SERIALIZATION_OPTION_KEYS.include?(key.to_sym)
57
+ end
58
+ end
59
+
60
+ def encode_value(value, depth)
61
+ if serializable_object?(value)
62
+ return with_cycle_guard(value) do
63
+ encode_value(materialize(value), depth)
64
+ end
65
+ end
66
+
67
+ case value
68
+ when Hash
69
+ with_cycle_guard(value) { encode_hash(value, depth) }
70
+ when Array
71
+ with_cycle_guard(value) { encode_array(value, depth) }
72
+ when Absent
73
+ "_"
74
+ when NilClass
75
+ "null"
76
+ when TrueClass, FalseClass
77
+ value.to_s
78
+ when Integer
79
+ value.to_s
80
+ when Float
81
+ encode_float(value)
82
+ when BigDecimal
83
+ encode_big_decimal(value)
84
+ when Rational
85
+ typed_scalar("!rational", value.to_s)
86
+ when Complex
87
+ typed_scalar("!complex", encode_complex(value))
88
+ when DateTime
89
+ typed_scalar("!datetime", value.iso8601(@time_precision))
90
+ when Date
91
+ typed_scalar("!date", value.iso8601)
92
+ when String
93
+ encode_token(value)
94
+ when Symbol
95
+ encode_token(value.to_s)
96
+ when Time
97
+ typed_scalar("!datetime", value.iso8601(@time_precision))
98
+ else
99
+ raise UnsupportedTypeError,
100
+ "cannot encode #{value.class}; provide a Hash, Array, scalar, or serializable_hash"
101
+ end
102
+ end
103
+
104
+ def serializable_object?(value)
105
+ return false if value.is_a?(Hash) || value.is_a?(Array)
106
+ return false if value.is_a?(String) || value.is_a?(Symbol)
107
+ return false if value.is_a?(Numeric) || value.is_a?(Date) || value.is_a?(Time)
108
+ return false if value.is_a?(Absent)
109
+ return false if value.nil? || value.equal?(true) || value.equal?(false)
110
+
111
+ value.respond_to?(:serializable_hash) || value.respond_to?(:to_ary) || value.respond_to?(:to_h)
112
+ end
113
+
114
+ def materialize(value)
115
+ if value.respond_to?(:serializable_hash)
116
+ serialized = serializable_hash(value)
117
+ unless serialized.is_a?(Hash)
118
+ raise UnsupportedTypeError,
119
+ "#{value.class}#serializable_hash must return a Hash, got #{serialized.class}"
120
+ end
121
+ return serialized
122
+ end
123
+
124
+ if value.respond_to?(:to_ary)
125
+ array = value.to_ary
126
+ return array if array.is_a?(Array)
127
+ end
128
+
129
+ if value.respond_to?(:to_h)
130
+ hash = value.to_h
131
+ return hash if hash.is_a?(Hash)
132
+ end
133
+
134
+ raise UnsupportedTypeError,
135
+ "#{value.class}#to_ary or #to_h must return an Array or Hash"
136
+ end
137
+
138
+ def serializable_hash(value)
139
+ method = value.method(:serializable_hash)
140
+ method.arity == 0 ? method.call : method.call(@serialization_options)
141
+ end
142
+
143
+ def encode_hash(hash, depth)
144
+ if record_hash?(hash)
145
+ encode_record(hash, depth)
146
+ else
147
+ encode_map(hash, depth)
148
+ end
149
+ end
150
+
151
+ def record_hash?(hash)
152
+ return false if @hash_mode == :map
153
+ return true if hash.empty? && @hash_mode != :map
154
+
155
+ unless hash.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
156
+ return false if @hash_mode == :auto
157
+
158
+ raise InvalidMapKeyError, "record keys must be Strings or Symbols"
159
+ end
160
+
161
+ names = hash.keys.map { |key| normalized_key_text(key) }
162
+ unique = names.uniq.length == names.length
163
+ if @hash_mode == :auto
164
+ raise InvalidMapKeyError, "record keys must be unique after Unicode normalization" unless unique
165
+
166
+ return true
167
+ end
168
+
169
+ raise InvalidMapKeyError, "record keys must be unique after Unicode normalization" unless unique
170
+
171
+ true
172
+ end
173
+
174
+ def encode_record(hash, depth)
175
+ fields = hash.each_with_object([]) do |(key, value), result|
176
+ next if @omit_absent && value.is_a?(Absent)
177
+
178
+ name = encode_token(key.to_s)
179
+ encoded_value = encode_value(value, depth + 1)
180
+ result << if @pretty
181
+ "#{name}: #{encoded_value}"
182
+ else
183
+ "#{name}:#{encoded_value}"
184
+ end
185
+ end
186
+
187
+ render_collection("{", "}", fields, depth)
188
+ end
189
+
190
+ def encode_map(hash, depth)
191
+ seen_keys = {}
192
+ entries = hash.each_with_object([]) do |(key, value), result|
193
+ if key.is_a?(Absent)
194
+ raise InvalidMapKeyError, "the TSON absent sentinel cannot be used as a map key"
195
+ end
196
+ next if @omit_absent && value.is_a?(Absent)
197
+
198
+ key_identity = map_key_identity(key)
199
+ if seen_keys.key?(key_identity)
200
+ raise InvalidMapKeyError, "map keys are not unique after TSON encoding: #{key.inspect}"
201
+ end
202
+ seen_keys[key_identity] = true
203
+
204
+ encoded_key = encode_value(key, depth + 1)
205
+ encoded_value = encode_value(value, depth + 1)
206
+ result << if @pretty
207
+ "#{encoded_key} => #{encoded_value}"
208
+ else
209
+ "#{encoded_key}=>#{encoded_value}"
210
+ end
211
+ end
212
+
213
+ render_collection("{", "}", entries, depth)
214
+ end
215
+
216
+ def map_key_identity(value)
217
+ case value
218
+ when String
219
+ [:scalar, normalized_key_text(value)]
220
+ when Symbol
221
+ [:scalar, normalized_key_text(value)]
222
+ when NilClass
223
+ [:scalar, "null"]
224
+ when TrueClass, FalseClass
225
+ [:scalar, value.to_s]
226
+ when Integer
227
+ [:scalar, value.to_s]
228
+ when Float
229
+ [:scalar, encode_float(value)]
230
+ when BigDecimal
231
+ [:scalar, encode_big_decimal(value)]
232
+ when Array
233
+ with_cycle_guard(value) do
234
+ [:array, value.map { |element| map_key_identity(element) }]
235
+ end
236
+ when Hash
237
+ with_cycle_guard(value) { compound_map_key_identity(value) }
238
+ else
239
+ if serializable_object?(value)
240
+ with_cycle_guard(value) { map_key_identity(materialize(value)) }
241
+ else
242
+ [:encoded, encode_value(value, 0)]
243
+ end
244
+ end
245
+ end
246
+
247
+ def compound_map_key_identity(hash)
248
+ entries = hash.each_with_object([]) do |(key, value), result|
249
+ next if @omit_absent && value.is_a?(Absent)
250
+
251
+ result << if record_hash?(hash)
252
+ [normalized_key_text(key), map_key_identity(value)]
253
+ else
254
+ [map_key_identity(key), map_key_identity(value)]
255
+ end
256
+ end
257
+
258
+ [record_hash?(hash) ? :record : :map, entries]
259
+ end
260
+
261
+ def encode_array(array, depth)
262
+ values = array.map { |value| encode_value(value, depth + 1) }
263
+ render_collection("[", "]", values, depth)
264
+ end
265
+
266
+ def render_collection(open, close, values, depth)
267
+ return "#{open}#{close}" if values.empty?
268
+
269
+ if @pretty
270
+ inner = values.map { |value| "#{@indent * (depth + 1)}#{value}" }.join("\n")
271
+ "#{open}\n#{inner}\n#{@indent * depth}#{close}"
272
+ else
273
+ "#{open}#{values.join(",")}#{close}"
274
+ end
275
+ end
276
+
277
+ def encode_float(value)
278
+ return ".nan" if value.nan?
279
+
280
+ case value.infinite?
281
+ when 1 then ".inf"
282
+ when -1 then "-.inf"
283
+ else value.to_s
284
+ end
285
+ end
286
+
287
+ def encode_big_decimal(value)
288
+ case value.infinite?
289
+ when 1 then typed_scalar("!float64", ".inf")
290
+ when -1 then typed_scalar("!float64", "-.inf")
291
+ else
292
+ typed_or_plain_number(value.to_s("F"))
293
+ end
294
+ end
295
+
296
+ def encode_complex(value)
297
+ real = encode_complex_component(value.real)
298
+ imaginary = encode_complex_component(value.imag)
299
+ separator = imaginary.start_with?("-") ? "-" : "+"
300
+ magnitude = imaginary.delete_prefix("-").delete_prefix("+")
301
+
302
+ "#{real}#{separator}#{magnitude}i"
303
+ end
304
+
305
+ def encode_complex_component(value)
306
+ case value
307
+ when Integer
308
+ value.to_s
309
+ when Float
310
+ if value.nan? || value.infinite?
311
+ raise UnsupportedTypeError, "cannot encode a Complex with a non-finite component"
312
+ end
313
+
314
+ value.to_s
315
+ when BigDecimal
316
+ unless value.finite?
317
+ raise UnsupportedTypeError, "cannot encode a Complex with a non-finite component"
318
+ end
319
+
320
+ value.to_s("F")
321
+ else
322
+ raise UnsupportedTypeError,
323
+ "cannot encode a Complex with #{value.class} components without losing precision"
324
+ end
325
+ end
326
+
327
+ def typed_or_plain_number(token)
328
+ return typed_scalar("!float64", ".nan") if token.casecmp("NaN").zero?
329
+
330
+ token
331
+ end
332
+
333
+ def typed_scalar(annotation, token)
334
+ "#{annotation} #{encode_token(token)}"
335
+ end
336
+
337
+ def encode_token(value)
338
+ text = utf8_string(value)
339
+ safe_unquoted?(text) ? text : %("#{escape_token(text)}")
340
+ end
341
+
342
+ def utf8_string(value)
343
+ text = value.to_s
344
+ text.encode(Encoding::UTF_8)
345
+ rescue EncodingError => error
346
+ raise InvalidStringError, "cannot encode invalid UTF-8 string: #{error.message}"
347
+ end
348
+
349
+ def normalized_key_text(value)
350
+ utf8_string(value).unicode_normalize(:nfc)
351
+ end
352
+
353
+ def safe_unquoted?(text)
354
+ return false unless text.match?(SAFE_UNQUOTED)
355
+ return false if text.start_with?("_")
356
+ return false if text.include?("..")
357
+ return false if %w[null true false].include?(text)
358
+ return false if NUMBER_TOKEN.match?(text) || SPECIAL_FLOAT_TOKEN.match?(text)
359
+ return false if %w[- + .].include?(text)
360
+
361
+ true
362
+ end
363
+
364
+ def escape_token(text)
365
+ escaped = String.new(encoding: Encoding::UTF_8)
366
+
367
+ text.each_codepoint do |codepoint|
368
+ escaped << case codepoint
369
+ when 0x08 then "\\b"
370
+ when 0x09 then "\\t"
371
+ when 0x0A then "\\n"
372
+ when 0x0C then "\\f"
373
+ when 0x0D then "\\r"
374
+ when 0x22 then '\\"'
375
+ when 0x5C then "\\\\"
376
+ when 0x00..0x1F, 0x7F, 0x85, 0x2028, 0x2029
377
+ format("\\u%04X", codepoint)
378
+ else
379
+ codepoint.chr(Encoding::UTF_8)
380
+ end
381
+ end
382
+
383
+ escaped
384
+ end
385
+
386
+ def with_cycle_guard(value)
387
+ object_id = value.object_id
388
+ if @active_path.key?(object_id)
389
+ raise CircularReferenceError, "cannot encode cyclic value at #{value.class}"
390
+ end
391
+
392
+ @active_path[object_id] = true
393
+ yield
394
+ ensure
395
+ @active_path.delete(object_id) if object_id
396
+ end
397
+ end
398
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsonRails
4
+ class Error < StandardError; end
5
+
6
+ class UnsupportedTypeError < Error; end
7
+
8
+ class CircularReferenceError < Error; end
9
+
10
+ class InvalidMapKeyError < Error; end
11
+
12
+ class InvalidStringError < Error; end
13
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsonRails
4
+ module RailsIntegration
5
+ def self.install!
6
+ register_mime_type
7
+ register_renderer
8
+ end
9
+
10
+ def self.register_mime_type
11
+ return if Mime::Type.lookup_by_extension(:tson)
12
+
13
+ Mime::Type.register(TsonRails::MIME_TYPE, :tson, [], [:tn1])
14
+ end
15
+
16
+ def self.register_renderer
17
+ return if ActionController::Renderers::RENDERERS.include?(:tson)
18
+
19
+ ActionController::Renderers.add :tson do |object, options|
20
+ self.content_type = Mime[:tson] if media_type.nil?
21
+ TsonRails.encode(object, options)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "action_controller/metal/renderers"
5
+ require "action_dispatch/http/mime_type"
6
+
7
+ module TsonRails
8
+ class Railtie < Rails::Railtie
9
+ initializer "tson_rails.register_renderer" do
10
+ TsonRails::RailsIntegration.install!
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TsonRails
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tson_rails.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tson_rails/version"
4
+ require_relative "tson_rails/error"
5
+ require_relative "tson_rails/absent"
6
+ require_relative "tson_rails/encoder"
7
+
8
+ module TsonRails
9
+ MIME_TYPE = "application/tson"
10
+
11
+ def self.encode(value, options = {})
12
+ Encoder.new(options).encode(value)
13
+ end
14
+
15
+ def self.install!
16
+ require_relative "tson_rails/rails_integration"
17
+ RailsIntegration.install!
18
+ end
19
+ end
20
+
21
+ if defined?(Rails::Railtie)
22
+ require_relative "tson_rails/rails_integration"
23
+ require_relative "tson_rails/railtie"
24
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
4
+
5
+ require "minitest/autorun"
6
+ require "tson_rails"
7
+ require "tson-rails"
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../test_helper"
4
+
5
+ class TsonRailsEncoderTest < Minitest::Test
6
+ SerializableUser = Struct.new(:id, :name) do
7
+ def serializable_hash(options = {})
8
+ values = { "id" => id, "name" => name }
9
+ only = Array(options[:only]).map(&:to_s) if options[:only]
10
+ only ? values.select { |key, _| only.include?(key) } : values
11
+ end
12
+ end
13
+
14
+ def test_encodes_a_rails_shaped_record
15
+ value = {
16
+ id: 1,
17
+ name: "Ada",
18
+ active: true,
19
+ deleted_at: nil,
20
+ tags: ["ruby", "rails"]
21
+ }
22
+
23
+ assert_equal(
24
+ "{id:1,name:Ada,active:true,deleted_at:null,tags:[ruby,rails]}",
25
+ TsonRails.encode(value)
26
+ )
27
+ end
28
+
29
+ def test_quotes_strings_that_would_change_meaning_or_break_lexing
30
+ value = {
31
+ "null" => "null",
32
+ "number" => "42",
33
+ "url" => "https://example.com/a?x=1",
34
+ "line" => "a\nb",
35
+ "_id" => "x"
36
+ }
37
+
38
+ assert_equal(
39
+ '{"null":"null",number:"42",url:"https://example.com/a?x=1",line:"a\\nb","_id":x}',
40
+ TsonRails.encode(value)
41
+ )
42
+ end
43
+
44
+ def test_encodes_non_string_keyed_hashes_as_maps
45
+ assert_equal "{1=>one,2=>two}", TsonRails.encode(1 => "one", 2 => "two")
46
+ end
47
+
48
+ def test_rejects_map_keys_that_become_textually_identical
49
+ assert_raises(TsonRails::InvalidMapKeyError) { TsonRails.encode("1" => "string", 1 => "integer") }
50
+ assert_raises(TsonRails::InvalidMapKeyError) { TsonRails.encode("name" => "string", :name => "symbol") }
51
+
52
+ string_key = { value: "1" }
53
+ integer_key = { value: 1 }
54
+ assert_raises(TsonRails::InvalidMapKeyError) do
55
+ TsonRails.encode(string_key => "string", integer_key => "integer")
56
+ end
57
+ end
58
+
59
+ def test_encodes_dates_times_and_special_floats
60
+ value = {
61
+ date: Date.new(2026, 8, 9),
62
+ time: Time.new(2026, 8, 9, 12, 34, 56, "+09:00"),
63
+ infinity: Float::INFINITY,
64
+ nan: Float::NAN,
65
+ negative_zero: -0.0
66
+ }
67
+
68
+ assert_equal(
69
+ '{date:!date 2026-08-09,time:!datetime "2026-08-09T12:34:56.000000000+09:00",infinity:.inf,nan:.nan,negative_zero:-0.0}',
70
+ TsonRails.encode(value)
71
+ )
72
+ end
73
+
74
+ def test_encodes_big_decimal_as_an_exact_number
75
+ assert_equal "!date 2026-08-09", TsonRails.encode(Date.new(2026, 8, 9))
76
+ assert_equal "123.45", TsonRails.encode(BigDecimal("123.4500"))
77
+ end
78
+
79
+ def test_encodes_complex_numbers_only_when_their_components_are_representable
80
+ assert_equal "!complex 1-2.5i", TsonRails.encode(Complex(1, -2.5))
81
+
82
+ error = assert_raises(TsonRails::UnsupportedTypeError) do
83
+ TsonRails.encode(Complex(Rational(1, 2), 1))
84
+ end
85
+ assert_match(/without losing precision/, error.message)
86
+ end
87
+
88
+ def test_encodes_ruby_absent_separately_from_nil
89
+ value = { present: TsonRails.absent, null_value: nil, items: [1, TsonRails.absent, 3] }
90
+
91
+ assert_equal "{present:_,null_value:null,items:[1,_,3]}", TsonRails.encode(value)
92
+ assert_equal "{null_value:null,items:[1,_,3]}", TsonRails.encode(value, omit_absent: true)
93
+ end
94
+
95
+ def test_supports_pretty_output
96
+ assert_equal(
97
+ "{\n user: {\n id: 1\n name: Ada\n }\n}",
98
+ TsonRails.encode({ user: { id: 1, name: "Ada" } }, pretty: true)
99
+ )
100
+ end
101
+
102
+ def test_uses_serializable_hash_and_passes_serialization_options
103
+ user = SerializableUser.new(1, "Ada")
104
+
105
+ assert_equal "{id:1,name:Ada}", TsonRails.encode(user)
106
+ assert_equal "{id:1}", TsonRails.encode(user, only: [:id])
107
+ end
108
+
109
+ def test_materializes_array_like_collections
110
+ collection = Object.new
111
+ collection.define_singleton_method(:to_ary) { [SerializableUser.new(1, "Ada")] }
112
+
113
+ assert_equal "[{id:1,name:Ada}]", TsonRails.encode(collection)
114
+ end
115
+
116
+ def test_rejects_cycles
117
+ value = []
118
+ value << value
119
+
120
+ assert_raises(TsonRails::CircularReferenceError) { TsonRails.encode(value) }
121
+ end
122
+
123
+ def test_rejects_record_and_map_keys_that_only_differ_by_unicode_normalization
124
+ composed = "\u00E9"
125
+ decomposed = "e\u0301"
126
+ value = { composed => 1, decomposed => 2 }
127
+
128
+ assert_raises(TsonRails::InvalidMapKeyError) { TsonRails.encode(value) }
129
+ assert_raises(TsonRails::InvalidMapKeyError) { TsonRails.encode(value, hash_mode: :map) }
130
+ end
131
+
132
+ def test_rejects_unsupported_objects
133
+ assert_raises(TsonRails::UnsupportedTypeError) { TsonRails.encode(Object.new) }
134
+ end
135
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "action_controller"
5
+ require "action_dispatch/testing/test_request"
6
+
7
+ require_relative "../test_helper"
8
+
9
+ TsonRails.install!
10
+
11
+ class TsonRailsRailsIntegrationTest < Minitest::Test
12
+ def test_registers_the_tson_mime_type_and_renderer
13
+ assert_equal "application/tson", Mime[:tson].to_s
14
+ assert_equal Mime[:tson], Mime::Type.lookup_by_extension(:tn1)
15
+ assert_includes ActionController::Renderers::RENDERERS, :tson
16
+ end
17
+
18
+ def test_render_tson_returns_a_tson_response
19
+ controller_class = Class.new(ActionController::Base) do
20
+ def show
21
+ render tson: { id: 1, name: "Ada" }, pretty: true
22
+ end
23
+ end
24
+
25
+ request = ActionDispatch::TestRequest.create
26
+ status, headers, body = controller_class.action(:show).call(request.env)
27
+
28
+ assert_equal 200, status
29
+ assert_equal "application/tson; charset=utf-8", headers["content-type"]
30
+ assert_equal "{\n id: 1\n name: Ada\n}", body.body
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tson-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - lef237
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '6.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ description: A Rails renderer and standalone Ruby encoder for the TSON text data format.
33
+ email: []
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - LICENSE
39
+ - README.md
40
+ - Rakefile
41
+ - lib/tson-rails.rb
42
+ - lib/tson_rails.rb
43
+ - lib/tson_rails/absent.rb
44
+ - lib/tson_rails/encoder.rb
45
+ - lib/tson_rails/error.rb
46
+ - lib/tson_rails/rails_integration.rb
47
+ - lib/tson_rails/railtie.rb
48
+ - lib/tson_rails/version.rb
49
+ - test/test_helper.rb
50
+ - test/tson_rails/encoder_test.rb
51
+ - test/tson_rails/rails_integration_test.rb
52
+ licenses:
53
+ - MIT
54
+ metadata: {}
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 3.6.9
70
+ specification_version: 4
71
+ summary: Render Rails data as Typed Schema Object Notation (TSON)
72
+ test_files: []