jscontact 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/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +212 -0
- data/SECURITY.md +6 -0
- data/data/enums.yml +356 -0
- data/data/types.yml +927 -0
- data/lib/jscontact/errors.rb +27 -0
- data/lib/jscontact/localization.rb +55 -0
- data/lib/jscontact/model.rb +236 -0
- data/lib/jscontact/patch_object.rb +89 -0
- data/lib/jscontact/types.rb +261 -0
- data/lib/jscontact/validator.rb +379 -0
- data/lib/jscontact/vcard/from_jscontact.rb +367 -0
- data/lib/jscontact/vcard/structured.rb +135 -0
- data/lib/jscontact/vcard/to_jscontact.rb +510 -0
- data/lib/jscontact/vcard.rb +78 -0
- data/lib/jscontact/version.rb +5 -0
- data/lib/jscontact.rb +45 -0
- data/sig/jscontact.rbs +495 -0
- metadata +104 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
4
|
+
Violation = Struct.new(:pointer, :message, keyword_init: true)
|
|
5
|
+
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :pointer
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, pointer: nil)
|
|
10
|
+
@pointer = pointer
|
|
11
|
+
super(message)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ParseError < Error; end
|
|
16
|
+
class ConversionError < Error; end
|
|
17
|
+
class LimitError < Error; end
|
|
18
|
+
|
|
19
|
+
class ValidationError < Error
|
|
20
|
+
attr_reader :errors
|
|
21
|
+
|
|
22
|
+
def initialize(errors, pointer: nil)
|
|
23
|
+
@errors = errors.is_a?(Array) ? errors : [Violation.new(pointer: pointer || "", message: errors)]
|
|
24
|
+
super(@errors.map { |error| "#{error.pointer}: #{error.message}" }.join("; "), pointer: pointer || @errors.first&.pointer)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
4
|
+
module Localization
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def validate(data, **limits)
|
|
8
|
+
localizations = data["localizations"]
|
|
9
|
+
return [] unless localizations.is_a?(Hash)
|
|
10
|
+
|
|
11
|
+
count = localizations.values.sum { |patch| patch.is_a?(Hash) ? patch.size : 0 }
|
|
12
|
+
raise LimitError.new("Too many localization patches", pointer: "/localizations") if count > limits.fetch(:max_patches, DEFAULT_LIMITS[:max_patches])
|
|
13
|
+
|
|
14
|
+
source = data.except("localizations")
|
|
15
|
+
localizations.flat_map do |language, patch|
|
|
16
|
+
prefix = Jscontact.pointer("/localizations", language)
|
|
17
|
+
next [] unless patch.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
forbidden = patch.keys.select { |path| path.is_a?(String) && (path == "localizations" || path.start_with?("localizations/")) }
|
|
20
|
+
unless forbidden.empty?
|
|
21
|
+
next forbidden.map { |path| Violation.new(pointer: Jscontact.pointer(prefix, path), message: "A localization cannot target localizations") }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
localized = PatchObject.new(patch, **limits).apply(source, validate: false)
|
|
26
|
+
Validator.new(**limits, validate_localizations: false).validate(localized).map do |error|
|
|
27
|
+
owner = patch.keys.find { |key| error.pointer == "/#{key}" || error.pointer.start_with?("/#{key}/") }
|
|
28
|
+
pointer = owner ? Jscontact.pointer(prefix, owner) + error.pointer.delete_prefix("/#{owner}") : prefix
|
|
29
|
+
Violation.new(pointer: pointer, message: "#{error.pointer}: #{error.message}")
|
|
30
|
+
end
|
|
31
|
+
rescue ValidationError => e
|
|
32
|
+
e.errors.map { |error| Violation.new(pointer: prefix + error.pointer.to_s, message: error.message) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Card
|
|
39
|
+
def localized(language)
|
|
40
|
+
raise ValidationError.new("Invalid language tag", pointer: "/language") unless language.is_a?(String) && Types.language_tag?(language)
|
|
41
|
+
|
|
42
|
+
validate!
|
|
43
|
+
source = to_h
|
|
44
|
+
localizations = source.fetch("localizations", {})
|
|
45
|
+
key = localizations.keys.find { |tag| tag.casecmp?(language) }
|
|
46
|
+
return self.class.new(source, **limits) unless key
|
|
47
|
+
|
|
48
|
+
patch = localizations.fetch(key)
|
|
49
|
+
source.delete("localizations")
|
|
50
|
+
result = PatchObject.new(patch, **limits).apply(source)
|
|
51
|
+
result["language"] = language
|
|
52
|
+
self.class.new(result, **limits).validate!
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
4
|
+
# A typed view over a JSON object; nested views write into their owning model.
|
|
5
|
+
class Model
|
|
6
|
+
attr_reader :limits
|
|
7
|
+
|
|
8
|
+
def initialize(data = nil, strict: false, explicit_types: false, **limits)
|
|
9
|
+
@limits = DEFAULT_LIMITS.merge(limits).freeze
|
|
10
|
+
validator = Validator.new(**limits)
|
|
11
|
+
data = { "@type" => "Card", "version" => "2.0" } if data.nil? && instance_of?(Card)
|
|
12
|
+
data ||= {}
|
|
13
|
+
raise ValidationError, "expected a JSON object" unless data.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
@data = Jscontact.deep_copy(data, max_depth: @limits[:max_depth])
|
|
16
|
+
@explicit_types = explicit_types
|
|
17
|
+
violations = validator.validate(@data, type: type_name, root: instance_of?(Card))
|
|
18
|
+
raise ValidationError, violations if strict && !violations.empty?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.wrap(data, limits, explicit_types)
|
|
22
|
+
value = allocate
|
|
23
|
+
value.instance_variable_set(:@data, data)
|
|
24
|
+
value.instance_variable_set(:@limits, limits)
|
|
25
|
+
value.instance_variable_set(:@explicit_types, explicit_types)
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def [](name)
|
|
30
|
+
name = name.to_s
|
|
31
|
+
rule = SCHEMA.fetch(type_name).fetch("properties")[name]
|
|
32
|
+
return @data[name] unless rule
|
|
33
|
+
return Jscontact.deep_copy(rule["default"]) if !@data.key?(name) && rule.key?("default")
|
|
34
|
+
|
|
35
|
+
wrap_value(@data[name], rule.fetch("type"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def []=(name, value)
|
|
39
|
+
@data[name.to_s] = Jscontact.deep_copy(value, max_depth: @limits[:max_depth])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def key?(name)
|
|
43
|
+
@data.key?(name.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delete(name)
|
|
47
|
+
@data.delete(name.to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
output = Jscontact.deep_copy(@data, max_depth: @limits[:max_depth])
|
|
52
|
+
add_types(output, type_name) if @explicit_types
|
|
53
|
+
output
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_json(options = nil)
|
|
57
|
+
validate!
|
|
58
|
+
data = to_h
|
|
59
|
+
data["@type"] ||= type_name
|
|
60
|
+
Jscontact.generate_json(data, options || { max_nesting: @limits[:max_depth] })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def errors
|
|
64
|
+
Validator.new(**@limits).validate(@data, type: type_name, root: instance_of?(Card))
|
|
65
|
+
end
|
|
66
|
+
alias warnings errors
|
|
67
|
+
|
|
68
|
+
def valid?
|
|
69
|
+
errors.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate!
|
|
73
|
+
violations = errors
|
|
74
|
+
raise ValidationError, violations unless violations.empty?
|
|
75
|
+
|
|
76
|
+
self
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ==(other)
|
|
80
|
+
other.is_a?(Model) && to_h == other.to_h
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
def type_name
|
|
86
|
+
self.class.name.split("::").last
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def wrap_value(value, signature)
|
|
92
|
+
return value unless value.is_a?(Hash) || value.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
if SCHEMA.key?(signature) && value.is_a?(Hash)
|
|
95
|
+
Jscontact.const_get(signature).wrap(value, @limits, @explicit_types)
|
|
96
|
+
elsif (match = /\A(?:Id|String)\[(\w+)\]\z/.match(signature)) && SCHEMA.key?(match[1]) && value.is_a?(Hash)
|
|
97
|
+
IdMap.new(value, type: match[1], limits: @limits, explicit_types: @explicit_types,
|
|
98
|
+
validate_ids: signature.start_with?("Id["))
|
|
99
|
+
elsif signature.end_with?("[]") && value.is_a?(Array)
|
|
100
|
+
value.map { |item| wrap_value(item, signature.delete_suffix("[]")) }
|
|
101
|
+
elsif signature.include?("|") && value.is_a?(Hash)
|
|
102
|
+
type = value.fetch("@type", signature.split("|").first)
|
|
103
|
+
SCHEMA.key?(type) ? Jscontact.const_get(type).wrap(value, @limits, @explicit_types) : value
|
|
104
|
+
else
|
|
105
|
+
value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def add_types(value, type)
|
|
110
|
+
return unless value.is_a?(Hash)
|
|
111
|
+
|
|
112
|
+
value["@type"] ||= type
|
|
113
|
+
SCHEMA.fetch(type).fetch("properties").each do |name, definition|
|
|
114
|
+
next unless value.key?(name)
|
|
115
|
+
|
|
116
|
+
item = value[name]
|
|
117
|
+
signature = definition.fetch("type")
|
|
118
|
+
if SCHEMA.key?(signature)
|
|
119
|
+
add_types(item, signature)
|
|
120
|
+
elsif (match = /\A(?:Id|String)\[(\w+)\]\z/.match(signature)) && SCHEMA.key?(match[1]) && item.is_a?(Hash)
|
|
121
|
+
item.each_value { |entry| add_types(entry, match[1]) }
|
|
122
|
+
elsif signature.end_with?("[]") && SCHEMA.key?(signature.delete_suffix("[]")) && item.is_a?(Array)
|
|
123
|
+
item.each { |entry| add_types(entry, signature.delete_suffix("[]")) }
|
|
124
|
+
elsif signature.include?("|") && item.is_a?(Hash)
|
|
125
|
+
nested_type = item.fetch("@type", signature.split("|").first)
|
|
126
|
+
add_types(item, nested_type) if SCHEMA.key?(nested_type)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# A map preserving source identifiers and exposing typed member values.
|
|
133
|
+
class IdMap
|
|
134
|
+
include Enumerable
|
|
135
|
+
|
|
136
|
+
def initialize(data = {}, type:, limits: DEFAULT_LIMITS, explicit_types: false, validate_ids: true)
|
|
137
|
+
@data = data
|
|
138
|
+
@type = type
|
|
139
|
+
@limits = limits
|
|
140
|
+
@explicit_types = explicit_types
|
|
141
|
+
@validate_ids = validate_ids
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def [](id)
|
|
145
|
+
value = @data[id.to_s]
|
|
146
|
+
value.is_a?(Hash) ? Jscontact.const_get(@type).wrap(value, @limits, @explicit_types) : value
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def []=(id, value)
|
|
150
|
+
id = id.to_s
|
|
151
|
+
raise ValidationError.new("invalid map Id", pointer: Jscontact.pointer("", id)) if @validate_ids && !Types.id?(id)
|
|
152
|
+
raise LimitError, "map exceeds max_map_size" if !@data.key?(id) && @data.size >= @limits[:max_map_size]
|
|
153
|
+
|
|
154
|
+
@data[id] = Jscontact.deep_copy(value, max_depth: @limits[:max_depth])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def each
|
|
158
|
+
return enum_for(:each) unless block_given?
|
|
159
|
+
|
|
160
|
+
@data.each_key { |id| yield id, self[id] }
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def keys
|
|
165
|
+
@data.keys
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def values
|
|
169
|
+
map { |_id, value| value }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def size
|
|
173
|
+
@data.size
|
|
174
|
+
end
|
|
175
|
+
alias length size
|
|
176
|
+
|
|
177
|
+
def empty?
|
|
178
|
+
@data.empty?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def key?(id)
|
|
182
|
+
@data.key?(id.to_s)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def delete(id)
|
|
186
|
+
value = self[id]
|
|
187
|
+
@data.delete(id.to_s)
|
|
188
|
+
value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def add(value, prefix: "id")
|
|
192
|
+
counter = 1
|
|
193
|
+
counter += 1 while @data.key?("#{prefix}#{counter}")
|
|
194
|
+
id = "#{prefix}#{counter}"
|
|
195
|
+
self[id] = value
|
|
196
|
+
id
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def to_h
|
|
200
|
+
Jscontact.deep_copy(@data, max_depth: @limits[:max_depth])
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
SCHEMA.each do |name, definition|
|
|
205
|
+
model = const_set(name, Class.new(Model))
|
|
206
|
+
definition.fetch("properties").each_key do |property|
|
|
207
|
+
next if property == "@type"
|
|
208
|
+
|
|
209
|
+
model.class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
|
|
210
|
+
# def full; self["full"]; end
|
|
211
|
+
def #{property}; self[#{property.inspect}]; end
|
|
212
|
+
# def full=(value); self["full"] = value; end
|
|
213
|
+
def #{property}=(value); self[#{property.inspect}] = value; end
|
|
214
|
+
RUBY
|
|
215
|
+
snake = property.gsub(/([A-Z])/, '_\\1').downcase
|
|
216
|
+
next if snake == property
|
|
217
|
+
|
|
218
|
+
model.alias_method(snake, property)
|
|
219
|
+
model.alias_method("#{snake}=", "#{property}=")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
class Card
|
|
224
|
+
def self.build(version: "2.0", explicit_types: false, **properties)
|
|
225
|
+
card = new({ "@type" => "Card", "version" => version }.merge(properties), explicit_types: explicit_types)
|
|
226
|
+
yield card if block_given?
|
|
227
|
+
card.validate!
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Returns the stable Id allocated for the new email address.
|
|
231
|
+
def add_email(**properties)
|
|
232
|
+
self.emails = {} unless key?("emails")
|
|
233
|
+
emails.add(properties, prefix: "e")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
4
|
+
class PatchObject
|
|
5
|
+
def initialize(patches, max_patches: 1_000, **limits)
|
|
6
|
+
raise ArgumentError, "max_patches must be a nonnegative integer" unless max_patches.is_a?(Integer) && max_patches >= 0
|
|
7
|
+
raise ValidationError, "PatchObject must be an object" unless patches.is_a?(Hash)
|
|
8
|
+
raise LimitError.new("Too many patches", pointer: "") if patches.size > max_patches
|
|
9
|
+
|
|
10
|
+
@limits = limits.merge(max_patches: max_patches)
|
|
11
|
+
@patches = Jscontact.deep_copy(patches, max_depth: @limits.fetch(:max_depth, 32))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_h
|
|
15
|
+
Jscontact.deep_copy(@patches, max_depth: @limits.fetch(:max_depth, 32))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# RFC 9553 section 1.4.3: resolve every path before modifying the copy.
|
|
19
|
+
def apply(data, validate: true)
|
|
20
|
+
raise ValidationError, "Patch target must be an object" unless data.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
result = Jscontact.deep_copy(data, max_depth: @limits.fetch(:max_depth, 32))
|
|
23
|
+
type = result["@type"]
|
|
24
|
+
validator = Validator.new(**@limits, validate_localizations: false)
|
|
25
|
+
errors = validator.validate_json(result) + validator.validate_json(@patches)
|
|
26
|
+
raise ValidationError, errors unless errors.empty?
|
|
27
|
+
|
|
28
|
+
paths = @patches.keys.map { |path| [tokens(path), path] }.sort_by(&:first)
|
|
29
|
+
paths.each_cons(2) do |(first, _), (second, path)|
|
|
30
|
+
invalid(path, "Patch paths must not overlap") if second.first(first.size) == first
|
|
31
|
+
end
|
|
32
|
+
operations = paths.map do |parts, path|
|
|
33
|
+
parent = parts[0...-1].reduce(result) { |value, token| member(value, token, path) }
|
|
34
|
+
key = parts.last
|
|
35
|
+
if parent.is_a?(Array)
|
|
36
|
+
key = array_index(parent, key, path)
|
|
37
|
+
invalid(path, "Array members cannot be removed") if @patches[path].nil?
|
|
38
|
+
elsif !parent.is_a?(Hash)
|
|
39
|
+
invalid(path, "Patch parent must be an object or array")
|
|
40
|
+
end
|
|
41
|
+
[parent, key, @patches[path]]
|
|
42
|
+
end
|
|
43
|
+
operations.each do |parent, key, value|
|
|
44
|
+
if value.nil?
|
|
45
|
+
parent.delete(key)
|
|
46
|
+
else
|
|
47
|
+
parent[key] = Jscontact.deep_copy(value, max_depth: @limits.fetch(:max_depth, 32))
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
errors = if validate && (type || result["@type"])
|
|
51
|
+
raise ValidationError.new("Unknown patch target type", pointer: "/@type") unless SCHEMA.key?(type || result["@type"])
|
|
52
|
+
|
|
53
|
+
validator.validate(result, type: type || result["@type"], root: true)
|
|
54
|
+
else
|
|
55
|
+
validator.validate_json(result)
|
|
56
|
+
end
|
|
57
|
+
raise ValidationError, errors unless errors.empty?
|
|
58
|
+
|
|
59
|
+
result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def tokens(path)
|
|
65
|
+
invalid(path, "Invalid JSON Pointer escape") if path.match?(/~(?:[^01]|\z)/)
|
|
66
|
+
(path.empty? ? [""] : path.split("/", -1)).map { |token| token.gsub(/~[01]/) { |escape| escape == "~0" ? "~" : "/" } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def array_index(array, token, path)
|
|
70
|
+
valid = token.match?(/\A(?:0|[1-9][0-9]*)\z/) && token.bytesize <= array.size.to_s.size && token.to_i < array.size
|
|
71
|
+
invalid(path, "Array index must refer to an existing member") unless valid
|
|
72
|
+
token.to_i
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def member(parent, key, path)
|
|
76
|
+
if parent.is_a?(Array)
|
|
77
|
+
parent[array_index(parent, key, path)]
|
|
78
|
+
elsif parent.is_a?(Hash) && parent.key?(key)
|
|
79
|
+
parent[key]
|
|
80
|
+
else
|
|
81
|
+
invalid(path, "Intermediate reference token does not exist")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def invalid(path, message)
|
|
86
|
+
raise ValidationError.new(message, pointer: "/#{path.gsub("~", "~0").gsub("/", "~1")}")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jscontact
|
|
4
|
+
DEFAULT_LIMITS = { max_input_size: 16 * 1024 * 1024, max_depth: 32, max_map_size: 10_000,
|
|
5
|
+
max_data_uri_size: 1024 * 1024, max_patches: 1000 }.freeze
|
|
6
|
+
MAX_INTEGER = (2**53) - 1
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def generate_json(value, options = nil)
|
|
10
|
+
value = precise_json_floats(value) if RUBY_ENGINE == "truffleruby"
|
|
11
|
+
JSON.generate(value, options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# TruffleRuby Float#to_s can round away significant digits used by json's generator.
|
|
15
|
+
def precise_json_floats(value)
|
|
16
|
+
case value
|
|
17
|
+
when Hash then value.transform_values { |item| precise_json_floats(item) }
|
|
18
|
+
when Array then value.map { |item| precise_json_floats(item) }
|
|
19
|
+
when Float
|
|
20
|
+
raise JSON::GeneratorError, "#{value} not allowed in JSON" unless value.finite?
|
|
21
|
+
|
|
22
|
+
number = format("%.17g", value)
|
|
23
|
+
number += ".0" unless number.match?(/[.eE]/)
|
|
24
|
+
JSON::Fragment.new(number)
|
|
25
|
+
else value
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
private :precise_json_floats
|
|
29
|
+
|
|
30
|
+
# Copies JSON-compatible values without sharing mutable caller-owned containers.
|
|
31
|
+
def deep_copy(value, max_depth: DEFAULT_LIMITS[:max_depth], depth: 0, ancestors: [], pointer: "")
|
|
32
|
+
case value
|
|
33
|
+
when Hash, Array
|
|
34
|
+
raise LimitError.new("JSON nesting exceeds max_depth", pointer: pointer) if depth >= max_depth
|
|
35
|
+
raise ValidationError.new("cyclic JSON data", pointer: pointer) if ancestors.include?(value.object_id)
|
|
36
|
+
|
|
37
|
+
nested = ancestors + [value.object_id]
|
|
38
|
+
if value.is_a?(Array)
|
|
39
|
+
value.each_with_index.map do |item, index|
|
|
40
|
+
deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested, pointer: self.pointer(pointer, index))
|
|
41
|
+
end
|
|
42
|
+
else
|
|
43
|
+
value.each_with_object({}) do |(key, item), result|
|
|
44
|
+
raise ValidationError.new("JSON object keys must be strings", pointer: pointer) unless key.is_a?(String) || key.is_a?(Symbol)
|
|
45
|
+
|
|
46
|
+
key = key.to_s
|
|
47
|
+
unless key.valid_encoding? && (key.encoding == Encoding::UTF_8 || key.ascii_only?)
|
|
48
|
+
raise ValidationError.new("invalid JSON object key encoding", pointer: pointer)
|
|
49
|
+
end
|
|
50
|
+
raise ValidationError.new("duplicate JSON object key #{key}", pointer: self.pointer(pointer, key)) if result.key?(key)
|
|
51
|
+
|
|
52
|
+
result[key.dup] = deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested,
|
|
53
|
+
pointer: self.pointer(pointer, key))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
when String then value.dup
|
|
57
|
+
when Integer, Float, TrueClass, FalseClass, NilClass then value
|
|
58
|
+
when Model, IdMap then value.to_h
|
|
59
|
+
else raise ValidationError.new("value is not JSON-compatible", pointer: pointer)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def deep_freeze(value)
|
|
64
|
+
if value.is_a?(Hash)
|
|
65
|
+
value.each do |key, item|
|
|
66
|
+
deep_freeze(key)
|
|
67
|
+
deep_freeze(item)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
value.each { |item| deep_freeze(item) } if value.is_a?(Array)
|
|
71
|
+
value.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def pointer(parent, key)
|
|
75
|
+
"#{parent}/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
SCHEMA = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/types.yml", __dir__)))
|
|
80
|
+
ENUMS = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/enums.yml", __dir__)))
|
|
81
|
+
|
|
82
|
+
module Types
|
|
83
|
+
module_function
|
|
84
|
+
|
|
85
|
+
def id?(value)
|
|
86
|
+
value.is_a?(String) && value.bytesize.between?(1, 255) && value.match?(/\A[A-Za-z0-9_-]+\z/)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def integer?(value, minimum: -MAX_INTEGER, maximum: MAX_INTEGER)
|
|
90
|
+
value.is_a?(Integer) && value.between?(minimum, maximum)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def script?(value)
|
|
94
|
+
value.is_a?(String) && value.match?(/\A[A-Za-z]{4}\z/) &&
|
|
95
|
+
(ENUMS.fetch("script").include?(value.capitalize) || value.downcase.between?("qaaa", "qabx"))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def vendor?(value)
|
|
99
|
+
return false unless value.is_a?(String) && value.valid_encoding?
|
|
100
|
+
|
|
101
|
+
prefix, name = value.split(":", 2)
|
|
102
|
+
return false unless name && !prefix.empty? && !name.empty? && !name.match?(%r{[\x00-\x08\x0A-\x1F\x7F"/~]})
|
|
103
|
+
|
|
104
|
+
prefix.split(".", -1).all? do |label|
|
|
105
|
+
label.match?(/\A[A-Za-z0-9\u0080-\u{10ffff}](?:[A-Za-z0-9\u0080-\u{10ffff}-]*[A-Za-z0-9\u0080-\u{10ffff}])?\z/)
|
|
106
|
+
end
|
|
107
|
+
rescue ArgumentError, Encoding::CompatibilityError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def uri?(value)
|
|
112
|
+
value.is_a?(String) && value.ascii_only? && !value.match?(/%(?![0-9a-fA-F]{2})/) && !URI.parse(value).scheme.nil?
|
|
113
|
+
rescue URI::InvalidURIError
|
|
114
|
+
false
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def utc_date_time?(value)
|
|
118
|
+
return false unless value.is_a?(String)
|
|
119
|
+
|
|
120
|
+
match = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d*[1-9])?Z\z/.match(value)
|
|
121
|
+
return false unless match
|
|
122
|
+
|
|
123
|
+
year, month, day, hour, minute, second = match.captures.map(&:to_i)
|
|
124
|
+
Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60 &&
|
|
125
|
+
(second < 60 || (hour == 23 && minute == 59 && [6, 12].include?(month) && day == Date.new(year, month, -1).day))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def jcard_temporal?(value, type)
|
|
129
|
+
return false unless value.is_a?(String)
|
|
130
|
+
|
|
131
|
+
case type
|
|
132
|
+
when "date" then jcard_date?(value)
|
|
133
|
+
when "time" then jcard_time?(value)
|
|
134
|
+
when "utc-offset"
|
|
135
|
+
match = /\A[+-](\d{2})(?::(\d{2}))?\z/.match(value)
|
|
136
|
+
match && match[1].to_i <= 23 && match[2].to_i <= 59
|
|
137
|
+
when "date-and-or-time"
|
|
138
|
+
if value.start_with?("T")
|
|
139
|
+
jcard_time?(value[1..])
|
|
140
|
+
else
|
|
141
|
+
jcard_temporal?(value, value.include?("T") ? "date-time" : "date")
|
|
142
|
+
end
|
|
143
|
+
else
|
|
144
|
+
day, clock, extra = value.split("T", -1)
|
|
145
|
+
return false if extra || !clock || !jcard_date?(day) || !jcard_time?(clock)
|
|
146
|
+
return false unless day.match?(/\A(?:\d{4}-\d{2}-\d{2}|--\d{2}-\d{2}|---\d{2})\z/) && !clock.start_with?("-")
|
|
147
|
+
|
|
148
|
+
type != "timestamp" || (day.match?(/\A\d{4}-\d{2}-\d{2}\z/) && clock.match?(/\A\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}(?::\d{2})?)?\z/))
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def jcard_date?(value)
|
|
153
|
+
if (match = /\A(?:(\d{4})-|--)(\d{2})-(\d{2})\z/.match(value))
|
|
154
|
+
return Date.valid_date?((match[1] || "2000").to_i, match[2].to_i, match[3].to_i, Date::GREGORIAN)
|
|
155
|
+
end
|
|
156
|
+
return true if value.match?(/\A\d{4}\z/)
|
|
157
|
+
return (1..31).cover?(match[1].to_i) if (match = /\A---(\d{2})\z/.match(value))
|
|
158
|
+
|
|
159
|
+
match = /\A(?:\d{4}-|--)(\d{2})\z/.match(value)
|
|
160
|
+
match && (1..12).cover?(match[1].to_i)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def jcard_time?(value)
|
|
164
|
+
match = /\A(\d{2}(?::\d{2}){0,2}|-\d{2}(?::\d{2})?|--\d{2})(Z|[+-]\d{2}(?::\d{2})?)?\z/.match(value)
|
|
165
|
+
return false unless match
|
|
166
|
+
|
|
167
|
+
clock, zone = match.captures
|
|
168
|
+
return false if zone && zone != "Z" && !jcard_temporal?(zone, "utc-offset")
|
|
169
|
+
|
|
170
|
+
offset = clock[/\A-*/].length
|
|
171
|
+
units = clock.delete_prefix("-" * offset).split(":").map(&:to_i)
|
|
172
|
+
units.each_with_index.all? { |unit, index| unit <= [23, 59, 60][index + offset] }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
GRANDFATHERED_LANGUAGES = %w[art-lojban cel-gaulish en-gb-oed i-ami i-bnn i-default i-enochian i-hak i-klingon
|
|
176
|
+
i-lux i-mingo i-navajo i-pwn i-tao i-tay i-tsu no-bok no-nyn sgn-be-fr sgn-be-nl
|
|
177
|
+
sgn-ch-de zh-guoyu zh-hakka zh-min zh-min-nan zh-xiang].freeze
|
|
178
|
+
|
|
179
|
+
def language_tag?(value)
|
|
180
|
+
return false unless value.is_a?(String)
|
|
181
|
+
return true if GRANDFATHERED_LANGUAGES.include?(value.downcase)
|
|
182
|
+
return true if value.match?(/\Ax(?:-[A-Za-z0-9]{1,8})+\z/i)
|
|
183
|
+
|
|
184
|
+
parts = value.split("-", -1)
|
|
185
|
+
language = parts.shift
|
|
186
|
+
return false unless language&.match?(/\A[A-Za-z]{2,8}\z/)
|
|
187
|
+
|
|
188
|
+
3.times { parts.shift if parts.first&.match?(/\A[A-Za-z]{3}\z/) } if language.size <= 3
|
|
189
|
+
parts.shift if parts.first&.match?(/\A[A-Za-z]{4}\z/)
|
|
190
|
+
parts.shift if parts.first&.match?(/\A(?:[A-Za-z]{2}|[0-9]{3})\z/)
|
|
191
|
+
variants = []
|
|
192
|
+
while parts.first&.match?(/\A(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})\z/)
|
|
193
|
+
variant = parts.shift.downcase
|
|
194
|
+
return false if variants.include?(variant)
|
|
195
|
+
|
|
196
|
+
variants << variant
|
|
197
|
+
end
|
|
198
|
+
singletons = []
|
|
199
|
+
until parts.empty?
|
|
200
|
+
singleton = parts.shift.downcase
|
|
201
|
+
return false unless singleton.match?(/\A[A-Za-z0-9]\z/) && !singletons.include?(singleton)
|
|
202
|
+
return !parts.empty? && parts.all? { |part| part.match?(/\A[A-Za-z0-9]{1,8}\z/) } if singleton == "x"
|
|
203
|
+
|
|
204
|
+
singletons << singleton
|
|
205
|
+
count = 0
|
|
206
|
+
while parts.first&.match?(/\A[A-Za-z0-9]{2,8}\z/)
|
|
207
|
+
parts.shift
|
|
208
|
+
count += 1
|
|
209
|
+
end
|
|
210
|
+
return false if count.zero?
|
|
211
|
+
end
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def email?(value)
|
|
216
|
+
return false unless value.is_a?(String) && value.ascii_only?
|
|
217
|
+
|
|
218
|
+
value = value.gsub(/\r\n(?=[ \t])/, "")
|
|
219
|
+
return false if value.match?(/[^\x09\x20-\x7E]/)
|
|
220
|
+
|
|
221
|
+
value = without_email_comments(value)
|
|
222
|
+
return false unless value
|
|
223
|
+
|
|
224
|
+
atom = %q([A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)
|
|
225
|
+
quoted = '"(?:[^"\\\\\r\n]|\\\\[\\x20-\\x7E])*"'
|
|
226
|
+
word = "(?:#{atom}|#{quoted})"
|
|
227
|
+
local = "#{word}(?:[ \\t]*\\.[ \\t]*#{word})*"
|
|
228
|
+
domain = "(?:#{atom}(?:[ \\t]*\\.[ \\t]*#{atom})*|\\[(?:[^\\[\\]\\\\\\r\\n]|\\\\[\\x20-\\x7E])*\\])"
|
|
229
|
+
value.match?(/\A[ \t]*#{local}[ \t]*@[ \t]*#{domain}[ \t]*\z/)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def without_email_comments(value)
|
|
233
|
+
result = +""
|
|
234
|
+
depth = 0
|
|
235
|
+
quoted = nil
|
|
236
|
+
escaped = false
|
|
237
|
+
value.each_char do |character|
|
|
238
|
+
if escaped
|
|
239
|
+
result << character if depth.zero?
|
|
240
|
+
escaped = false
|
|
241
|
+
elsif character == "\\" && (depth.positive? || quoted)
|
|
242
|
+
escaped = true
|
|
243
|
+
result << character if depth.zero?
|
|
244
|
+
elsif depth.positive?
|
|
245
|
+
depth += 1 if character == "("
|
|
246
|
+
depth -= 1 if character == ")"
|
|
247
|
+
result << " " if depth.zero?
|
|
248
|
+
elsif quoted
|
|
249
|
+
result << character
|
|
250
|
+
quoted = nil if character == quoted
|
|
251
|
+
elsif character == "("
|
|
252
|
+
depth = 1
|
|
253
|
+
else
|
|
254
|
+
result << character
|
|
255
|
+
quoted = character == "[" ? "]" : '"' if ['"', "["].include?(character)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
result if depth.zero? && !quoted && !escaped
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|