aspace_client 0.0.1
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/lib/aspace_client.rb +15 -0
- data/lib/aspace_client/archivesspace_json_schema.rb +210 -0
- data/lib/aspace_client/asutils.rb +142 -0
- data/lib/aspace_client/client_enum_source.rb +30 -0
- data/lib/aspace_client/exceptions.rb +15 -0
- data/lib/aspace_client/helpers.rb +0 -0
- data/lib/aspace_client/json_schema_concurrency_fix.rb +52 -0
- data/lib/aspace_client/json_schema_utils.rb +414 -0
- data/lib/aspace_client/jsonmodel.rb +342 -0
- data/lib/aspace_client/jsonmodel_client.rb +528 -0
- data/lib/aspace_client/jsonmodel_i18n_mixin.rb +77 -0
- data/lib/aspace_client/jsonmodel_type.rb +478 -0
- data/lib/aspace_client/memoryleak.rb +59 -0
- data/lib/aspace_client/schemas/abstract_agent.rb +51 -0
- data/lib/aspace_client/schemas/abstract_agent_relationship.rb +12 -0
- data/lib/aspace_client/schemas/abstract_archival_object.rb +96 -0
- data/lib/aspace_client/schemas/abstract_classification.rb +44 -0
- data/lib/aspace_client/schemas/abstract_name.rb +23 -0
- data/lib/aspace_client/schemas/abstract_note.rb +13 -0
- data/lib/aspace_client/schemas/accession.rb +174 -0
- data/lib/aspace_client/schemas/accession_parts_relationship.rb +31 -0
- data/lib/aspace_client/schemas/accession_sibling_relationship.rb +31 -0
- data/lib/aspace_client/schemas/active_edits.rb +23 -0
- data/lib/aspace_client/schemas/advanced_query.rb +12 -0
- data/lib/aspace_client/schemas/agent_contact.rb +25 -0
- data/lib/aspace_client/schemas/agent_corporate_entity.rb +32 -0
- data/lib/aspace_client/schemas/agent_family.rb +29 -0
- data/lib/aspace_client/schemas/agent_person.rb +31 -0
- data/lib/aspace_client/schemas/agent_relationship_associative.rb +28 -0
- data/lib/aspace_client/schemas/agent_relationship_earlierlater.rb +28 -0
- data/lib/aspace_client/schemas/agent_relationship_parentchild.rb +26 -0
- data/lib/aspace_client/schemas/agent_relationship_subordinatesuperior.rb +26 -0
- data/lib/aspace_client/schemas/agent_software.rb +22 -0
- data/lib/aspace_client/schemas/archival_object.rb +60 -0
- data/lib/aspace_client/schemas/archival_record_children.rb +15 -0
- data/lib/aspace_client/schemas/boolean_field_query.rb +13 -0
- data/lib/aspace_client/schemas/boolean_query.rb +13 -0
- data/lib/aspace_client/schemas/classification.rb +10 -0
- data/lib/aspace_client/schemas/classification_term.rb +38 -0
- data/lib/aspace_client/schemas/classification_tree.rb +17 -0
- data/lib/aspace_client/schemas/collection_management.rb +27 -0
- data/lib/aspace_client/schemas/container.rb +29 -0
- data/lib/aspace_client/schemas/container_location.rb +19 -0
- data/lib/aspace_client/schemas/date.rb +19 -0
- data/lib/aspace_client/schemas/date_field_query.rb +14 -0
- data/lib/aspace_client/schemas/deaccession.rb +20 -0
- data/lib/aspace_client/schemas/defaults.rb +104 -0
- data/lib/aspace_client/schemas/digital_object.rb +64 -0
- data/lib/aspace_client/schemas/digital_object_component.rb +53 -0
- data/lib/aspace_client/schemas/digital_object_tree.rb +19 -0
- data/lib/aspace_client/schemas/digital_record_children.rb +15 -0
- data/lib/aspace_client/schemas/enumeration.rb +29 -0
- data/lib/aspace_client/schemas/enumeration_migration.rb +14 -0
- data/lib/aspace_client/schemas/event.rb +88 -0
- data/lib/aspace_client/schemas/extent.rb +17 -0
- data/lib/aspace_client/schemas/external_document.rb +12 -0
- data/lib/aspace_client/schemas/external_id.rb +11 -0
- data/lib/aspace_client/schemas/field_query.rb +15 -0
- data/lib/aspace_client/schemas/file_version.rb +26 -0
- data/lib/aspace_client/schemas/group.rb +17 -0
- data/lib/aspace_client/schemas/instance.rb +27 -0
- data/lib/aspace_client/schemas/job.rb +57 -0
- data/lib/aspace_client/schemas/location.rb +36 -0
- data/lib/aspace_client/schemas/location_batch.rb +45 -0
- data/lib/aspace_client/schemas/merge_request.rb +48 -0
- data/lib/aspace_client/schemas/name_corporate_entity.rb +15 -0
- data/lib/aspace_client/schemas/name_family.rb +13 -0
- data/lib/aspace_client/schemas/name_form.rb +15 -0
- data/lib/aspace_client/schemas/name_person.rb +19 -0
- data/lib/aspace_client/schemas/name_software.rb +14 -0
- data/lib/aspace_client/schemas/note_abstract.rb +17 -0
- data/lib/aspace_client/schemas/note_bibliography.rb +29 -0
- data/lib/aspace_client/schemas/note_bioghist.rb +22 -0
- data/lib/aspace_client/schemas/note_chronology.rb +28 -0
- data/lib/aspace_client/schemas/note_citation.rb +32 -0
- data/lib/aspace_client/schemas/note_definedlist.rb +25 -0
- data/lib/aspace_client/schemas/note_digital_object.rb +23 -0
- data/lib/aspace_client/schemas/note_index.rb +29 -0
- data/lib/aspace_client/schemas/note_index_item.rb +25 -0
- data/lib/aspace_client/schemas/note_multipart.rb +25 -0
- data/lib/aspace_client/schemas/note_orderedlist.rb +27 -0
- data/lib/aspace_client/schemas/note_outline.rb +20 -0
- data/lib/aspace_client/schemas/note_outline_level.rb +21 -0
- data/lib/aspace_client/schemas/note_singlepart.rb +24 -0
- data/lib/aspace_client/schemas/note_text.rb +17 -0
- data/lib/aspace_client/schemas/permission.rb +15 -0
- data/lib/aspace_client/schemas/preference.rb +16 -0
- data/lib/aspace_client/schemas/record_tree.rb +17 -0
- data/lib/aspace_client/schemas/repository.rb +32 -0
- data/lib/aspace_client/schemas/repository_with_agent.rb +14 -0
- data/lib/aspace_client/schemas/resource.rb +112 -0
- data/lib/aspace_client/schemas/resource_tree.rb +20 -0
- data/lib/aspace_client/schemas/rights_statement.rb +35 -0
- data/lib/aspace_client/schemas/subject.rb +30 -0
- data/lib/aspace_client/schemas/term.rb +16 -0
- data/lib/aspace_client/schemas/user.rb +56 -0
- data/lib/aspace_client/schemas/user_defined.rb +42 -0
- data/lib/aspace_client/schemas/vocabulary.rb +15 -0
- data/lib/aspace_client/validations.rb +434 -0
- data/lib/aspace_client/validator_cache.rb +47 -0
- data/lib/aspace_client/version.rb +3 -0
- metadata +244 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
module JSONSchemaUtils
|
|
2
|
+
|
|
3
|
+
def self.fragment_join(fragment, property = nil)
|
|
4
|
+
fragment = fragment.gsub(/^#\//, "")
|
|
5
|
+
property = property.gsub(/^#\//, "") if property
|
|
6
|
+
|
|
7
|
+
if property && fragment != "" && fragment !~ /\/$/
|
|
8
|
+
fragment = "#{fragment}/"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
"#{fragment}#{property}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def self.schema_path_lookup(schema, path)
|
|
16
|
+
if path.is_a? String
|
|
17
|
+
return self.schema_path_lookup(schema, path.split("/"))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
if schema.has_key?('properties')
|
|
21
|
+
schema = schema['properties']
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
if path.length == 1
|
|
25
|
+
schema[path.first]
|
|
26
|
+
else
|
|
27
|
+
if schema[path.first]
|
|
28
|
+
self.schema_path_lookup(schema[path.first], path.drop(1))
|
|
29
|
+
else
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SCHEMA_PARSE_RULES =
|
|
38
|
+
[
|
|
39
|
+
{
|
|
40
|
+
:failed_attribute => ['Properties', 'IfMissing', 'ArchivesSpaceSubType'],
|
|
41
|
+
:pattern => /([A-Z]+: )?The property '.*?' did not contain a required property of '(.*?)'.*/,
|
|
42
|
+
:do => ->(msgs, message, path, type, property) {
|
|
43
|
+
if type && type =~ /ERROR/
|
|
44
|
+
msgs[:errors][fragment_join(path, property)] = ["Property is required but was missing"]
|
|
45
|
+
else
|
|
46
|
+
msgs[:warnings][fragment_join(path, property)] = ["Property was missing"]
|
|
47
|
+
end
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
:failed_attribute => ['ArchivesSpaceType'],
|
|
53
|
+
:pattern => /The property '#(.*?)' was not a well-formed date/,
|
|
54
|
+
:do => ->(msgs, message, path, property) {
|
|
55
|
+
msgs[:errors][fragment_join(path)] = ["Not a valid date"]
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
{
|
|
60
|
+
:failed_attribute => ['Pattern'],
|
|
61
|
+
:pattern => /The property '#\/.*?' did not match the regex '(.*?)' in schema/,
|
|
62
|
+
:do => ->(msgs, message, path, regexp) {
|
|
63
|
+
msgs[:errors][fragment_join(path)] = ["Did not match regular expression: #{regexp}"]
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
:failed_attribute => ['MinLength'],
|
|
69
|
+
:pattern => /The property '#\/.*?' was not of a minimum string length of ([0-9]+) in schema/,
|
|
70
|
+
:do => ->(msgs, message, path, length) {
|
|
71
|
+
msgs[:errors][fragment_join(path)] = ["Must be at least #{length} characters"]
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
:failed_attribute => ['MaxLength'],
|
|
77
|
+
:pattern => /The property '#\/.*?' was not of a maximum string length of ([0-9]+) in schema/,
|
|
78
|
+
:do => ->(msgs, message, path, length) {
|
|
79
|
+
msgs[:errors][fragment_join(path)] = ["Must be #{length} characters or fewer"]
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
{
|
|
84
|
+
:failed_attribute => ['MinItems'],
|
|
85
|
+
:pattern => /The property '#\/.*?' did not contain a minimum number of items ([0-9]+) in schema/,
|
|
86
|
+
:do => ->(msgs, message, path, items) {
|
|
87
|
+
msgs[:errors][fragment_join(path)] = ["At least #{items} item(s) is required"]
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
{
|
|
92
|
+
:failed_attribute => ['Enum'],
|
|
93
|
+
:pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
|
|
94
|
+
:do => ->(msgs, message, path, invalid, valid_set) {
|
|
95
|
+
msgs[:errors][fragment_join(path)] = ["Invalid value '#{invalid}'. Must be one of: #{valid_set}"]
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
:failed_attribute => ['ArchivesSpaceDynamicEnum'],
|
|
101
|
+
:pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
|
|
102
|
+
:do => ->(msgs, message, path, invalid, valid_set) {
|
|
103
|
+
msgs[:attribute_types][fragment_join(path)] = 'ArchivesSpaceDynamicEnum'
|
|
104
|
+
msgs[:errors][fragment_join(path)] = ["Invalid value '#{invalid}'. Must be one of: #{valid_set}"]
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
:failed_attribute => ['ArchivesSpaceReadOnlyDynamicEnum'],
|
|
109
|
+
:pattern => /The property '#\/.*?' value "(.*?)" .*values: (.*) in schema/,
|
|
110
|
+
:do => ->(msgs, message, path, invalid, valid_set) {
|
|
111
|
+
msgs[:attribute_types][fragment_join(path)] = 'ArchivesSpaceReadOnlyDynamicEnum'
|
|
112
|
+
msgs[:errors][fragment_join(path)] = ["Protected read-only list #{path}. Invalid value '#{invalid}'. Must be one of: #{valid_set}"]
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
:failed_attribute => ['Type', 'ArchivesSpaceType'],
|
|
118
|
+
:pattern => /The property '#\/.*?' of type (.*?) did not match the following type: (.*?) in schema/,
|
|
119
|
+
:do => ->(msgs, message, path, actual_type, desired_type) {
|
|
120
|
+
if actual_type !~ /JSONModel/ || message[:failed_attribute] == 'ArchivesSpaceType'
|
|
121
|
+
# We'll skip JSONModels because the specific problem with the
|
|
122
|
+
# document will have already been listed separately.
|
|
123
|
+
|
|
124
|
+
msgs[:state][fragment_join(path)] ||= []
|
|
125
|
+
msgs[:state][fragment_join(path)] << desired_type
|
|
126
|
+
|
|
127
|
+
if msgs[:state][fragment_join(path)].length == 1
|
|
128
|
+
msgs[:errors][fragment_join(path)] = ["Must be a #{desired_type} (you provided a #{actual_type})"]
|
|
129
|
+
else
|
|
130
|
+
msgs[:errors][fragment_join(path)] = ["Must be one of: #{msgs[:state][fragment_join(path)].join (", ")} (you provided a #{actual_type})"]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
{
|
|
138
|
+
:failed_attribute => ['custom_validation'],
|
|
139
|
+
:pattern => /Validation failed for '(.*?)': (.*?) in schema /,
|
|
140
|
+
:do => ->(msgs, message, path, property, msg) {
|
|
141
|
+
property = (property && !property.empty?) ? property : nil
|
|
142
|
+
msgs[:errors][fragment_join(path, property)] = [msg]
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
:failed_attribute => ['custom_validation'],
|
|
148
|
+
:pattern => /Warning generated for '(.*?)': (.*?) in schema /,
|
|
149
|
+
:do => ->(msgs, message, path, property, msg) {
|
|
150
|
+
msgs[:warnings][fragment_join(path, property)] = [msg]
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
:failed_attribute => ['custom_validation'],
|
|
156
|
+
:pattern => /Validation error code: (.*?) in schema /,
|
|
157
|
+
:do => ->(msgs, message, path, error_code) {
|
|
158
|
+
msgs[:errors]['coded_errors'] = [error_code]
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Catch all
|
|
164
|
+
{
|
|
165
|
+
:failed_attribute => nil,
|
|
166
|
+
:pattern => /^(.*)$/,
|
|
167
|
+
:do => ->(msgs, message, path, msg) {
|
|
168
|
+
msgs[:errors]['unknown'] = [msg]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# For a given error, find its list of sub errors.
|
|
175
|
+
def self.extract_suberrors(errors)
|
|
176
|
+
errors = Array[errors].flatten
|
|
177
|
+
|
|
178
|
+
result = errors.map do |error|
|
|
179
|
+
if !error[:errors]
|
|
180
|
+
error
|
|
181
|
+
else
|
|
182
|
+
self.extract_suberrors(error[:errors])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
result.flatten
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Given a list of error messages produced by JSON schema validation, parse
|
|
192
|
+
# them into a structured format like:
|
|
193
|
+
#
|
|
194
|
+
# {
|
|
195
|
+
# :errors => {:attr1 => "(What was wrong with attr1)"},
|
|
196
|
+
# :warnings => {:attr2 => "(attr2 not quite right either)"}
|
|
197
|
+
# }
|
|
198
|
+
def self.parse_schema_messages(messages, validator)
|
|
199
|
+
|
|
200
|
+
messages = self.extract_suberrors(messages)
|
|
201
|
+
|
|
202
|
+
msgs = {
|
|
203
|
+
:errors => {},
|
|
204
|
+
:warnings => {},
|
|
205
|
+
# to lookup e.g., msgs[:attribute_types]['extents/0/extent_type'] => 'ArchivesSpaceDynamicEnum'
|
|
206
|
+
:attribute_types => {},
|
|
207
|
+
:state => {} # give the parse rules somewhere to store useful state for a run
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
messages.each do |message|
|
|
211
|
+
|
|
212
|
+
SCHEMA_PARSE_RULES.each do |rule|
|
|
213
|
+
if (rule[:failed_attribute].nil? || rule[:failed_attribute].include?(message[:failed_attribute])) and
|
|
214
|
+
message[:message] =~ rule[:pattern]
|
|
215
|
+
rule[:do].call(msgs, message, message[:fragment],
|
|
216
|
+
*message[:message].scan(rule[:pattern]).flatten)
|
|
217
|
+
|
|
218
|
+
break
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
msgs.delete(:state)
|
|
225
|
+
msgs
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# Given a hash representing a record tree, map across the hash and this
|
|
230
|
+
# model's schema in lockstep.
|
|
231
|
+
#
|
|
232
|
+
# Each proc in the 'transformations' array is called with the current node
|
|
233
|
+
# in the record tree as its first argument, and the part of the schema
|
|
234
|
+
# that corresponds to it. Whatever the proc returns is used to replace
|
|
235
|
+
# the node in the record tree.
|
|
236
|
+
#
|
|
237
|
+
def self.map_hash_with_schema(record, schema, transformations = [])
|
|
238
|
+
return record if not record.is_a?(Hash)
|
|
239
|
+
|
|
240
|
+
if schema.is_a?(String)
|
|
241
|
+
schema = resolve_schema_reference(schema)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Sometimes a schema won't specify anything other than the required type
|
|
245
|
+
# (like {'type' => 'object'}). If there's nothing more to check, we're
|
|
246
|
+
# done.
|
|
247
|
+
return record if !schema.has_key?("properties")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Apply transformations to the current level of the tree
|
|
251
|
+
transformations.each do |transform|
|
|
252
|
+
record = transform.call(record, schema)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Now figure out how to traverse the remainder of the tree...
|
|
256
|
+
result = {}
|
|
257
|
+
|
|
258
|
+
record.each do |k, v|
|
|
259
|
+
k = k.to_s
|
|
260
|
+
properties = schema['properties']
|
|
261
|
+
|
|
262
|
+
if properties.has_key?(k) && (properties[k]["type"] == "object")
|
|
263
|
+
result[k] = self.map_hash_with_schema(v, properties[k], transformations)
|
|
264
|
+
|
|
265
|
+
elsif v.is_a?(Array) && properties.has_key?(k) && (properties[k]["type"] == "array")
|
|
266
|
+
|
|
267
|
+
# Arrays are tricky because they can either consist of a single type, or
|
|
268
|
+
# a number of different types.
|
|
269
|
+
|
|
270
|
+
if properties[k]["items"]["type"].is_a?(Array)
|
|
271
|
+
result[k] = v.map {|elt|
|
|
272
|
+
|
|
273
|
+
if elt.is_a?(Hash)
|
|
274
|
+
next_schema = determine_schema_for(elt, properties[k]["items"]["type"])
|
|
275
|
+
self.map_hash_with_schema(elt, next_schema, transformations)
|
|
276
|
+
elsif elt.is_a?(Array)
|
|
277
|
+
raise "Nested arrays aren't supported here (yet)"
|
|
278
|
+
else
|
|
279
|
+
elt
|
|
280
|
+
end
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# The array contains a single type of object
|
|
284
|
+
elsif properties[k]["items"]["type"] === "object"
|
|
285
|
+
result[k] = v.map {|elt| self.map_hash_with_schema(elt, properties[k]["items"], transformations)}
|
|
286
|
+
else
|
|
287
|
+
# Just one valid type
|
|
288
|
+
result[k] = v.map {|elt| self.map_hash_with_schema(elt, properties[k]["items"]["type"], transformations)}
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
elsif (v.is_a?(Hash) || v.is_a?(Array)) && (properties.has_key?(k) && properties[k]["type"].is_a?(Array))
|
|
292
|
+
# Multiple possible types for this single value
|
|
293
|
+
|
|
294
|
+
results = (v.is_a?(Array) ? v : [v]).map {|elt|
|
|
295
|
+
next_schema = determine_schema_for(elt, properties[k]["type"])
|
|
296
|
+
self.map_hash_with_schema(elt, next_schema, transformations)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
result[k] = v.is_a?(Array) ? results : results[0]
|
|
300
|
+
|
|
301
|
+
elsif properties.has_key?(k) && JSONModel.parse_jsonmodel_ref(properties[k]["type"])
|
|
302
|
+
result[k] = self.map_hash_with_schema(v, properties[k]["type"], transformations)
|
|
303
|
+
else
|
|
304
|
+
result[k] = v
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
result
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def self.is_blank?(obj)
|
|
314
|
+
obj.nil? || obj == "" || obj == {}
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def self.drop_empty_elements(obj)
|
|
319
|
+
if obj.is_a?(Hash)
|
|
320
|
+
Hash[obj.map do |k, v|
|
|
321
|
+
v = drop_empty_elements(v)
|
|
322
|
+
[k, v] if !is_blank?(v)
|
|
323
|
+
end]
|
|
324
|
+
elsif obj.is_a?(Array)
|
|
325
|
+
obj.map {|elt| drop_empty_elements(elt)}.reject {|elt| is_blank?(elt)}
|
|
326
|
+
else
|
|
327
|
+
obj
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Drop any keys from 'hash' that aren't defined in the JSON schema.
|
|
333
|
+
#
|
|
334
|
+
# If drop_readonly is true, also drop any values where the schema has
|
|
335
|
+
# 'readonly' set to true. These values are produced by the system for the
|
|
336
|
+
# client, but are not part of the data model.
|
|
337
|
+
#
|
|
338
|
+
def self.drop_unknown_properties(hash, schema, drop_readonly = false)
|
|
339
|
+
fn = proc do |hash, schema|
|
|
340
|
+
result = {}
|
|
341
|
+
|
|
342
|
+
hash.each do |k, v|
|
|
343
|
+
if schema["properties"].has_key?(k.to_s) && (!drop_readonly || !schema["properties"][k.to_s]["readonly"])
|
|
344
|
+
result[k] = v
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
result
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
hash = drop_empty_elements(hash)
|
|
352
|
+
map_hash_with_schema(hash, schema, [fn])
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def self.apply_schema_defaults(hash, schema)
|
|
357
|
+
fn = proc do |hash, schema|
|
|
358
|
+
result = hash.clone
|
|
359
|
+
|
|
360
|
+
schema["properties"].each do |property, definition|
|
|
361
|
+
|
|
362
|
+
if definition.has_key?("default") && !hash.has_key?(property.to_s) && !hash.has_key?(property.intern)
|
|
363
|
+
result[property] = definition["default"]
|
|
364
|
+
elsif definition['type'] == 'array' && !hash.has_key?(property.to_s) && !hash.has_key?(property.intern)
|
|
365
|
+
# Array values that weren't provided default to empty
|
|
366
|
+
result[property] = []
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
result
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
map_hash_with_schema(hash, schema, [fn])
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def self.resolve_schema_reference(schema_reference)
|
|
381
|
+
# This should be a reference to a different JSONModel type. Resolve it
|
|
382
|
+
# and return its schema.
|
|
383
|
+
ref = JSONModel.parse_jsonmodel_ref(schema_reference)
|
|
384
|
+
raise "Invalid schema given: #{schema_reference}" if !ref
|
|
385
|
+
|
|
386
|
+
JSONModel.JSONModel(ref[0]).schema
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def self.determine_schema_for(elt, possible_schemas)
|
|
391
|
+
# A number of different types. Match them up based on the value of the 'jsonmodel_type' property
|
|
392
|
+
schema_types = possible_schemas.map {|schema| schema.is_a?(Hash) ? schema["type"] : schema}
|
|
393
|
+
|
|
394
|
+
jsonmodel_type = elt["jsonmodel_type"]
|
|
395
|
+
|
|
396
|
+
if !jsonmodel_type
|
|
397
|
+
raise JSONModel::ValidationException.new(:errors => {"record" => ["Can't unambiguously match #{elt.inspect} against schema types: #{schema_types.inspect}. " +
|
|
398
|
+
"Resolve this by adding a 'jsonmodel_type' property to #{elt.inspect}"]})
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
next_schema = schema_types.find {|type|
|
|
402
|
+
(type.is_a?(String) && type.include?("JSONModel(:#{jsonmodel_type})")) ||
|
|
403
|
+
(type.is_a?(Hash) && type["jsonmodel_type"] === jsonmodel_type)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if next_schema.nil?
|
|
407
|
+
raise "Couldn't determine type of '#{elt.inspect}'. Must be one of: #{schema_types.inspect}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
next_schema
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
end
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
require 'json-schema'
|
|
2
|
+
require 'atomic'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require_relative 'jsonmodel_type'
|
|
5
|
+
require_relative 'json_schema_concurrency_fix'
|
|
6
|
+
require_relative 'json_schema_utils'
|
|
7
|
+
require_relative 'asutils'
|
|
8
|
+
require_relative 'validator_cache'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
module JSONModel
|
|
12
|
+
|
|
13
|
+
@@models = {}
|
|
14
|
+
@@custom_validations = {}
|
|
15
|
+
@@strict_mode = false
|
|
16
|
+
@@init_args = {:initialized => false}
|
|
17
|
+
|
|
18
|
+
def self.custom_validations
|
|
19
|
+
@@custom_validations
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def strict_mode(val)
|
|
23
|
+
@@strict_mode = val
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def self.strict_mode?
|
|
28
|
+
@@strict_mode
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ValidationException < StandardError
|
|
33
|
+
attr_accessor :invalid_object
|
|
34
|
+
attr_accessor :errors
|
|
35
|
+
attr_accessor :warnings
|
|
36
|
+
attr_accessor :attribute_types
|
|
37
|
+
attr_accessor :import_context
|
|
38
|
+
attr_accessor :object_context
|
|
39
|
+
|
|
40
|
+
def initialize(opts)
|
|
41
|
+
@invalid_object = opts[:invalid_object]
|
|
42
|
+
@errors = opts[:errors]
|
|
43
|
+
@import_context = opts[:import_context]
|
|
44
|
+
@object_context = opts[:object_context]
|
|
45
|
+
@attribute_types = opts[:attribute_types]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s
|
|
49
|
+
msg = { :errors => @errors }
|
|
50
|
+
msg[:import_context] = @import_context unless @import_context.nil?
|
|
51
|
+
msg[:object_context] = @object_context unless @object_context.nil?
|
|
52
|
+
"#<:ValidationException: #{msg.inspect}>"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def self.JSONModel(source)
|
|
58
|
+
if !@@models.has_key?(source.to_s)
|
|
59
|
+
load_schema(source.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@@models[source.to_s] or raise "JSONModel not found for #{source}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def JSONModel(source)
|
|
67
|
+
JSONModel.JSONModel(source)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Yield all known JSONModel classes
|
|
72
|
+
def models
|
|
73
|
+
@@models
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def self.repository_for(reference)
|
|
78
|
+
if reference =~ /^(\/repositories\/[0-9]+)\//
|
|
79
|
+
return $1
|
|
80
|
+
else
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# Parse a URI reference like /repositories/123/archival_objects/500 into
|
|
87
|
+
# {:id => 500, :type => :archival_object}
|
|
88
|
+
def self.parse_reference(reference, opts = {})
|
|
89
|
+
@@models.each do |type, model|
|
|
90
|
+
id = model.id_for(reference, opts, true)
|
|
91
|
+
if id
|
|
92
|
+
return {:id => id, :type => type, :repository => repository_for(reference)}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def self.destroy_model(type)
|
|
101
|
+
@@models.delete(type.to_s)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def self.schema_src(schema_name)
|
|
106
|
+
|
|
107
|
+
if schema_name.to_s !~ /\A[0-9A-Za-z_-]+\z/
|
|
108
|
+
raise "Invalid schema name: #{schema_name}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[*ASUtils.find_local_directories('schemas'),
|
|
112
|
+
File.join(File.dirname(__FILE__), "schemas")].each do |dir|
|
|
113
|
+
|
|
114
|
+
schema = File.join(dir, "#{schema_name}.rb")
|
|
115
|
+
|
|
116
|
+
if File.exists?(schema)
|
|
117
|
+
return File.open(schema).read
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def self.allow_unmapped_enum_value(val, magic_value = 'other_unmapped')
|
|
126
|
+
if val.is_a? Array
|
|
127
|
+
val.each { |elt| allow_unmapped_enum_value(elt) }
|
|
128
|
+
elsif val.is_a? Hash
|
|
129
|
+
val.each do |k, v|
|
|
130
|
+
if k == 'enum'
|
|
131
|
+
v << magic_value
|
|
132
|
+
else
|
|
133
|
+
allow_unmapped_enum_value(v)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def self.load_schema(schema_name)
|
|
141
|
+
if not @@models[schema_name]
|
|
142
|
+
|
|
143
|
+
old_verbose = $VERBOSE
|
|
144
|
+
$VERBOSE = nil
|
|
145
|
+
src = schema_src(schema_name)
|
|
146
|
+
|
|
147
|
+
return if !src
|
|
148
|
+
|
|
149
|
+
entry = eval(src)
|
|
150
|
+
$VERBOSE = old_verbose
|
|
151
|
+
|
|
152
|
+
parent = entry[:schema]["parent"]
|
|
153
|
+
if parent
|
|
154
|
+
load_schema(parent)
|
|
155
|
+
|
|
156
|
+
base = @@models[parent].schema["properties"].clone
|
|
157
|
+
properties = ASUtils.deep_merge(base, entry[:schema]["properties"])
|
|
158
|
+
|
|
159
|
+
# Maybe we'll eventually want the version of a schema to be
|
|
160
|
+
# automatically set to max(my_version, parent_version), but for now...
|
|
161
|
+
if entry[:schema]["version"] < @@models[parent].schema_version
|
|
162
|
+
raise ("Can't inherit from a JSON schema whose version is newer than ours " +
|
|
163
|
+
"(our (#{schema_name}) version: #{entry[:schema]['version']}; " +
|
|
164
|
+
"parent (#{parent}) version: #{@@models[parent].schema_version})")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
entry[:schema]["properties"] = properties
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# All records have a lock_version property that we use for optimistic concurrency control.
|
|
171
|
+
entry[:schema]["properties"]["lock_version"] = {"type" => ["integer", "string"], "required" => false}
|
|
172
|
+
|
|
173
|
+
# All records must indicate their model type
|
|
174
|
+
entry[:schema]["properties"]["jsonmodel_type"] = {"type" => "string", "ifmissing" => "error"}
|
|
175
|
+
|
|
176
|
+
# All records have audit fields
|
|
177
|
+
entry[:schema]["properties"]["created_by"] = {"type" => "string", "readonly" => true}
|
|
178
|
+
entry[:schema]["properties"]["last_modified_by"] = {"type" => "string", "readonly" => true}
|
|
179
|
+
entry[:schema]["properties"]["user_mtime"] = {"type" => "date-time", "readonly" => true}
|
|
180
|
+
entry[:schema]["properties"]["system_mtime"] = {"type" => "date-time", "readonly" => true}
|
|
181
|
+
entry[:schema]["properties"]["create_time"] = {"type" => "date-time", "readonly" => true}
|
|
182
|
+
|
|
183
|
+
# Records may include a reference to the repository that contains them
|
|
184
|
+
entry[:schema]["properties"]["repository"] ||= {
|
|
185
|
+
"type" => "object",
|
|
186
|
+
"subtype" => "ref",
|
|
187
|
+
"readonly" => "true",
|
|
188
|
+
"properties" => {
|
|
189
|
+
"ref" => {
|
|
190
|
+
"type" => "JSONModel(:repository) uri",
|
|
191
|
+
"ifmissing" => "error",
|
|
192
|
+
"readonly" => "true"
|
|
193
|
+
},
|
|
194
|
+
"_resolved" => {
|
|
195
|
+
"type" => "object",
|
|
196
|
+
"readonly" => "true"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if @@init_args && @@init_args[:allow_other_unmapped]
|
|
203
|
+
allow_unmapped_enum_value(entry[:schema]['properties'])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
ASUtils.find_local_directories("schemas/#{schema_name}_ext.rb").
|
|
207
|
+
select {|path| File.exists?(path)}.
|
|
208
|
+
each do |schema_extension|
|
|
209
|
+
entry[:schema]['properties'] = ASUtils.deep_merge(entry[:schema]['properties'],
|
|
210
|
+
eval(File.open(schema_extension).read))
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
self.create_model_for(schema_name, entry[:schema])
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def self.init(opts = {})
|
|
219
|
+
|
|
220
|
+
@@init_args ||= nil
|
|
221
|
+
|
|
222
|
+
# Skip initialisation if this model has already been loaded.
|
|
223
|
+
if @@init_args[:initialized]
|
|
224
|
+
return true
|
|
225
|
+
else
|
|
226
|
+
@@init_args[:initialized] = true
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if opts.has_key?(:strict_mode)
|
|
230
|
+
@@strict_mode = true
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
@@init_args = opts
|
|
234
|
+
|
|
235
|
+
if !opts.has_key?(:enum_source)
|
|
236
|
+
if opts[:client_mode]
|
|
237
|
+
require_relative 'jsonmodel_client'
|
|
238
|
+
opts[:enum_source] = JSONModel::Client::EnumSource.new
|
|
239
|
+
else
|
|
240
|
+
raise "Required JSONModel.init arg :enum_source was missing"
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Load all JSON schemas from the schemas subdirectory
|
|
245
|
+
# Create a model class for each one.
|
|
246
|
+
Dir.glob(File.join(File.dirname(__FILE__),
|
|
247
|
+
"schemas",
|
|
248
|
+
"*.rb")).sort.each do |schema|
|
|
249
|
+
schema_name = File.basename(schema, ".rb")
|
|
250
|
+
load_schema(schema_name)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
require_relative "validations"
|
|
254
|
+
|
|
255
|
+
# For dynamic enums, automatically slot in the 'other_unmapped' string as an allowable value
|
|
256
|
+
if @@init_args[:allow_other_unmapped]
|
|
257
|
+
enum_wrapper = Struct.new(:enum_source).new(@@init_args[:enum_source])
|
|
258
|
+
|
|
259
|
+
def enum_wrapper.valid?(name, value)
|
|
260
|
+
value == 'other_unmapped' || enum_source.valid?(name, value)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def enum_wrapper.editable?(name)
|
|
264
|
+
enum_source.editable?(name)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def enum_wrapper.values_for(name)
|
|
268
|
+
enum_source.values_for(name) + ['other_unmapped']
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def enum_wrapper.default_value_for(name)
|
|
272
|
+
enum_source.default_value_for(name)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
@@init_args[:enum_source] = enum_wrapper
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
true
|
|
279
|
+
|
|
280
|
+
rescue
|
|
281
|
+
# If anything went wrong we're not initialised.
|
|
282
|
+
@@init_args = nil
|
|
283
|
+
|
|
284
|
+
raise $!
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def self.enum_values(name)
|
|
289
|
+
@@init_args[:enum_source].values_for(name)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def self.enum_default_value(name)
|
|
294
|
+
@@init_args[:enum_source].default_value_for(name)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def self.client_mode?
|
|
299
|
+
@@init_args[:client_mode]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def self.parse_jsonmodel_ref(ref)
|
|
304
|
+
if ref.is_a? String and ref =~ /JSONModel\(:([a-zA-Z_\-]+)\) (.*)/
|
|
305
|
+
[$1.intern, $2]
|
|
306
|
+
else
|
|
307
|
+
nil
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
protected
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def self.clean_data(data)
|
|
316
|
+
data = ASUtils.keys_as_strings(data) if data.is_a?(Hash)
|
|
317
|
+
data = ASUtils.jsonmodels_to_hashes(data)
|
|
318
|
+
|
|
319
|
+
data
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# Create and return a new JSONModel class called 'type', based on the
|
|
324
|
+
# JSONSchema 'schema'
|
|
325
|
+
def self.create_model_for(type, schema)
|
|
326
|
+
|
|
327
|
+
cls = Class.new(JSONModelType)
|
|
328
|
+
cls.init(type, schema, Array(@@init_args[:mixins]))
|
|
329
|
+
|
|
330
|
+
@@models[type] = cls
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def self.init_args
|
|
335
|
+
@@init_args
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# Custom JSON schema validations
|
|
342
|
+
require_relative 'archivesspace_json_schema'
|