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 +7 -0
- data/LICENSE +21 -0
- data/README.md +100 -0
- data/Rakefile +13 -0
- data/lib/tson-rails.rb +3 -0
- data/lib/tson_rails/absent.rb +24 -0
- data/lib/tson_rails/encoder.rb +398 -0
- data/lib/tson_rails/error.rb +13 -0
- data/lib/tson_rails/rails_integration.rb +25 -0
- data/lib/tson_rails/railtie.rb +13 -0
- data/lib/tson_rails/version.rb +5 -0
- data/lib/tson_rails.rb +24 -0
- data/test/test_helper.rb +7 -0
- data/test/tson_rails/encoder_test.rb +135 -0
- data/test/tson_rails/rails_integration_test.rb +32 -0
- metadata +72 -0
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,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
|
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
|
data/test/test_helper.rb
ADDED
|
@@ -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: []
|