cocina-models 0.127.0 → 0.129.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.
@@ -6,6 +6,17 @@ module Cocina
6
6
  # an AdminPolicy. This is almost the same as DROAccess, but it provides no defaults
7
7
  # and has no embargo.
8
8
  class AdminPolicyAccessTemplate < Struct
9
+ # Access level.
10
+ # Validation of this property is relaxed. See the schema.json for full validation.
11
+ attribute? :view, Types::Strict::String.optional.default('dark')
12
+ # Download access level.
13
+ # Validation of this property is relaxed. See the schema.json for full validation.
14
+ attribute? :download, Types::Strict::String.optional.default('none')
15
+ # Not used for this access type, must be null.
16
+ # Validation of this property is relaxed. See the schema.json for full validation.
17
+ attribute? :location, Types::Strict::String.optional
18
+ # Validation of this property is relaxed. See the schema.json for full validation.
19
+ attribute? :controlledDigitalLending, Types::Strict::Bool.optional.default(false)
9
20
  # The human readable copyright statement that applies
10
21
  # example: Copyright World Trade Organization
11
22
  attribute? :copyright, Copyright.optional
@@ -18,17 +29,6 @@ module Cocina
18
29
  # The license governing reuse of the DRO. Should be an IRI for known licenses (i.e.
19
30
  # CC, RightsStatement.org URI, etc.).
20
31
  attribute? :license, License.optional.enum(nil, 'https://www.gnu.org/licenses/agpl.txt', 'https://www.apache.org/licenses/LICENSE-2.0', 'https://opensource.org/licenses/BSD-2-Clause', 'https://opensource.org/licenses/BSD-3-Clause', 'https://creativecommons.org/licenses/by/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode', 'https://creativecommons.org/licenses/by-nd/4.0/legalcode', 'https://creativecommons.org/licenses/by-sa/4.0/legalcode', 'https://creativecommons.org/publicdomain/zero/1.0/legalcode', 'https://opensource.org/licenses/cddl1', 'https://www.eclipse.org/legal/epl-2.0', 'https://www.gnu.org/licenses/gpl-3.0-standalone.html', 'https://www.isc.org/downloads/software-support-policy/isc-license/', 'https://www.gnu.org/licenses/lgpl-3.0-standalone.html', 'https://opensource.org/licenses/MIT', 'https://www.mozilla.org/MPL/2.0/', 'https://opendatacommons.org/licenses/by/1-0/', 'http://opendatacommons.org/licenses/odbl/1.0/', 'https://opendatacommons.org/licenses/odbl/1-0/', 'https://creativecommons.org/publicdomain/mark/1.0/', 'https://opendatacommons.org/licenses/pddl/1-0/', 'https://creativecommons.org/licenses/by/3.0/legalcode', 'https://creativecommons.org/licenses/by-sa/3.0/legalcode', 'https://creativecommons.org/licenses/by-nd/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc-nd/3.0/legalcode', 'https://cocina.sul.stanford.edu/licenses/none')
21
- # Access level.
22
- # Validation of this property is relaxed. See the schema.json for full validation.
23
- attribute? :view, Types::Strict::String.optional.default('dark')
24
- # Download access level.
25
- # Validation of this property is relaxed. See the schema.json for full validation.
26
- attribute? :download, Types::Strict::String.optional.default('none')
27
- # Not used for this access type, must be null.
28
- # Validation of this property is relaxed. See the schema.json for full validation.
29
- attribute? :location, Types::Strict::String.optional
30
- # Validation of this property is relaxed. See the schema.json for full validation.
31
- attribute? :controlledDigitalLending, Types::Strict::Bool.optional.default(false)
32
32
  end
33
33
  end
34
34
  end
@@ -2,10 +2,44 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- # Property model for indicating the parts, aspects, or versions of the resource to
6
- # which a descriptive element is applicable.
5
+ # Value model for appliesTo property.
7
6
  class AppliesTo < Struct
8
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
7
+ attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
8
+ attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
+ attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
10
+ # String or integer value of the descriptive element.
11
+ attribute? :value, Types::Nominal::Any
12
+ # Type of value provided by the descriptive element. See https://github.com/sul-dlss/cocina-models/blob/main/docs/description_types.md
13
+ # for valid types.
14
+ attribute? :type, Types::Strict::String
15
+ # Status of the descriptive element value relative to other instances of the element.
16
+ attribute? :status, Types::Strict::String
17
+ # Code value of the descriptive element.
18
+ attribute? :code, Types::Strict::String
19
+ # URI value of the descriptive element.
20
+ attribute? :uri, Types::Strict::String
21
+ # Property model for indicating the encoding, standard, or syntax to which a value
22
+ # conforms (e.g. RDA).
23
+ attribute? :standard, Standard.optional
24
+ # Property model for indicating the encoding, standard, or syntax to which a value
25
+ # conforms (e.g. RDA).
26
+ attribute? :encoding, Standard.optional
27
+ # Property model for indicating the vocabulary, authority, or other origin for a term,
28
+ # code, or identifier.
29
+ attribute? :source, Source.optional
30
+ # The preferred display label to use for the descriptive element in access systems.
31
+ attribute? :displayLabel, Types::Strict::String.optional
32
+ # A term providing information about the circumstances of the statement (e.g., approximate
33
+ # dates).
34
+ attribute? :qualifier, Types::Strict::String
35
+ # Other information related to the descriptive element.
36
+ attribute :note, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
37
+ # Language of the descriptive element value
38
+ attribute? :valueLanguage, DescriptiveValueLanguage.optional
39
+ # URL or other pointer to the location of the value of the descriptive element.
40
+ attribute? :valueAt, Types::Strict::String
41
+ # Identifiers and URIs associated with the descriptive element.
42
+ attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
9
43
  end
10
44
  end
11
45
  end
@@ -3,7 +3,7 @@
3
3
  module Cocina
4
4
  module Models
5
5
  # Basic value model for descriptive elements. Can only have one of value, parallelValue,
6
- # groupedValue, or structuredValue.
6
+ # groupedValue, or structuredValue. Does not have identifier or appliesTo properties.
7
7
  class DescriptiveBasicValue < Struct
8
8
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
9
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -25,8 +25,6 @@ module Cocina
25
25
  # Property model for indicating the encoding, standard, or syntax to which a value
26
26
  # conforms (e.g. RDA).
27
27
  attribute? :encoding, Standard.optional
28
- # Identifiers and URIs associated with the descriptive element.
29
- attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
30
28
  # Property model for indicating the vocabulary, authority, or other origin for a term,
31
29
  # code, or identifier.
32
30
  attribute? :source, Source.optional
@@ -2,10 +2,8 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- # Value model for descriptive identifiers. Same as DescriptiveValue but omits the identifier
6
- # property to prevent infinite recursion.
5
+ # Default value model for identifiers.
7
6
  class DescriptiveIdentifier < Struct
8
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
9
7
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
10
8
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
11
9
  attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -40,6 +38,7 @@ module Cocina
40
38
  attribute? :valueLanguage, DescriptiveValueLanguage.optional
41
39
  # URL or other pointer to the location of the value of the descriptive element.
42
40
  attribute? :valueAt, Types::Strict::String
41
+ attribute :appliesTo, Types::Strict::Array.of(AppliesTo).default([].freeze)
43
42
  end
44
43
  end
45
44
  end
@@ -4,7 +4,6 @@ module Cocina
4
4
  module Models
5
5
  # Default value model for descriptive elements.
6
6
  class DescriptiveValue < Struct
7
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
8
7
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
8
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
10
9
  attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -25,8 +24,6 @@ module Cocina
25
24
  # Property model for indicating the encoding, standard, or syntax to which a value
26
25
  # conforms (e.g. RDA).
27
26
  attribute? :encoding, Standard.optional
28
- # Identifiers and URIs associated with the descriptive element.
29
- attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
30
27
  # Property model for indicating the vocabulary, authority, or other origin for a term,
31
28
  # code, or identifier.
32
29
  attribute? :source, Source.optional
@@ -41,6 +38,9 @@ module Cocina
41
38
  attribute? :valueLanguage, DescriptiveValueLanguage.optional
42
39
  # URL or other pointer to the location of the value of the descriptive element.
43
40
  attribute? :valueAt, Types::Strict::String
41
+ attribute :appliesTo, Types::Strict::Array.of(AppliesTo).default([].freeze)
42
+ # Identifiers and URIs associated with the descriptive element.
43
+ attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
44
44
  end
45
45
  end
46
46
  end
@@ -3,6 +3,17 @@
3
3
  module Cocina
4
4
  module Models
5
5
  class DROAccess < Struct
6
+ # Access level.
7
+ # Validation of this property is relaxed. See the schema.json for full validation.
8
+ attribute? :view, Types::Strict::String.optional.default('dark')
9
+ # Download access level.
10
+ # Validation of this property is relaxed. See the schema.json for full validation.
11
+ attribute? :download, Types::Strict::String.optional.default('none')
12
+ # Not used for this access type, must be null.
13
+ # Validation of this property is relaxed. See the schema.json for full validation.
14
+ attribute? :location, Types::Strict::String.optional
15
+ # Validation of this property is relaxed. See the schema.json for full validation.
16
+ attribute? :controlledDigitalLending, Types::Strict::Bool.optional.default(false)
6
17
  # The human readable copyright statement that applies
7
18
  # example: Copyright World Trade Organization
8
19
  attribute? :copyright, Copyright.optional
@@ -16,17 +27,6 @@ module Cocina
16
27
  # The license governing reuse of the DRO. Should be an IRI for known licenses (i.e.
17
28
  # CC, RightsStatement.org URI, etc.).
18
29
  attribute? :license, License.optional.enum(nil, 'https://www.gnu.org/licenses/agpl.txt', 'https://www.apache.org/licenses/LICENSE-2.0', 'https://opensource.org/licenses/BSD-2-Clause', 'https://opensource.org/licenses/BSD-3-Clause', 'https://creativecommons.org/licenses/by/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc-nd/4.0/legalcode', 'https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode', 'https://creativecommons.org/licenses/by-nd/4.0/legalcode', 'https://creativecommons.org/licenses/by-sa/4.0/legalcode', 'https://creativecommons.org/publicdomain/zero/1.0/legalcode', 'https://opensource.org/licenses/cddl1', 'https://www.eclipse.org/legal/epl-2.0', 'https://www.gnu.org/licenses/gpl-3.0-standalone.html', 'https://www.isc.org/downloads/software-support-policy/isc-license/', 'https://www.gnu.org/licenses/lgpl-3.0-standalone.html', 'https://opensource.org/licenses/MIT', 'https://www.mozilla.org/MPL/2.0/', 'https://opendatacommons.org/licenses/by/1-0/', 'http://opendatacommons.org/licenses/odbl/1.0/', 'https://opendatacommons.org/licenses/odbl/1-0/', 'https://creativecommons.org/publicdomain/mark/1.0/', 'https://opendatacommons.org/licenses/pddl/1-0/', 'https://creativecommons.org/licenses/by/3.0/legalcode', 'https://creativecommons.org/licenses/by-sa/3.0/legalcode', 'https://creativecommons.org/licenses/by-nd/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode', 'https://creativecommons.org/licenses/by-nc-nd/3.0/legalcode', 'https://cocina.sul.stanford.edu/licenses/none')
19
- # Access level.
20
- # Validation of this property is relaxed. See the schema.json for full validation.
21
- attribute? :view, Types::Strict::String.optional.default('dark')
22
- # Download access level.
23
- # Validation of this property is relaxed. See the schema.json for full validation.
24
- attribute? :download, Types::Strict::String.optional.default('none')
25
- # Not used for this access type, must be null.
26
- # Validation of this property is relaxed. See the schema.json for full validation.
27
- attribute? :location, Types::Strict::String.optional
28
- # Validation of this property is relaxed. See the schema.json for full validation.
29
- attribute? :controlledDigitalLending, Types::Strict::Bool.optional.default(false)
30
30
  end
31
31
  end
32
32
  end
@@ -5,7 +5,7 @@ module Cocina
5
5
  # Languages, scripts, symbolic systems, and notations used in all or part of a resource
6
6
  # or its descriptive metadata.
7
7
  class Language < Struct
8
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
8
+ attribute :appliesTo, Types::Strict::Array.of(AppliesTo).default([].freeze)
9
9
  # Code value of the descriptive element.
10
10
  attribute? :code, Types::Strict::String
11
11
  # The preferred display label to use for the descriptive element in access systems.
@@ -37,8 +37,10 @@ module Cocina
37
37
  @notifier = notifier
38
38
  end
39
39
 
40
+ # @param [Boolean] include_related_resource whether to map nested relatedItem elements;
41
+ # false when building a relatedItem, since RelatedResource cannot itself nest a relatedResource
40
42
  # @return [Hash] a hash that can be mapped to a cocina description model
41
- def build(resource_element:, purl: nil, require_title: true)
43
+ def build(resource_element:, purl: nil, require_title: true, include_related_resource: true)
42
44
  cocina_description = {}
43
45
  title_result = @title_builder.build(resource_element: resource_element, require_title: require_title,
44
46
  notifier: notifier)
@@ -47,7 +49,8 @@ module Cocina
47
49
  purl_value = purl || Purl.primary_purl_value(resource_element, purl)
48
50
  cocina_description[:purl] = purl_value if purl_value
49
51
 
50
- BUILDERS.each do |description_property, builder|
52
+ builders = include_related_resource ? BUILDERS : BUILDERS.except(:relatedResource)
53
+ builders.each do |description_property, builder|
51
54
  result = builder.build(resource_element: resource_element, description_builder: self,
52
55
  purl: purl_value)
53
56
  cocina_description.merge!(description_property => result) if result.present?
@@ -88,7 +88,7 @@ module Cocina
88
88
  uri: ValueURI.sniff(subject_node['valueURI'], notifier)
89
89
  }.tap do |attrs|
90
90
  source = {
91
- code: subject_node['authority'],
91
+ value: subject_node['authority'],
92
92
  uri: subject_node['authorityURI']
93
93
  }.compact
94
94
  attrs[:source] = source if source.present?
@@ -80,7 +80,8 @@ module Cocina
80
80
  end
81
81
 
82
82
  def build_related_item(related_item)
83
- description_builder.build(resource_element: related_item, require_title: false).tap do |item|
83
+ description_builder.build(resource_element: related_item, require_title: false,
84
+ include_related_resource: false).tap do |item|
84
85
  item[:displayLabel] = related_item['displayLabel']
85
86
  if related_item['type']
86
87
  item[:type] = normalized_type_for(related_item['type'])
@@ -38,8 +38,6 @@ module Cocina
38
38
  attribute? :purl, Purl.optional
39
39
  # Information about how to access digital and physical versions of the object.
40
40
  attribute? :access, DescriptiveAccessMetadata.optional
41
- # Other resources associated with the related resource.
42
- attribute :relatedResource, Types::Strict::Array.of(RelatedResource).default([].freeze)
43
41
  # Information about this resource description.
44
42
  attribute? :adminMetadata, DescriptiveAdminMetadata.optional
45
43
  # The version of the related resource.
@@ -20,7 +20,7 @@ module Cocina
20
20
  attribute(:administrative, AdminPolicyAdministrative.default { AdminPolicyAdministrative.new })
21
21
  # Description that is included in a request to create a DRO. This is the same as a
22
22
  # Description, except excludes PURL.
23
- attribute? :description, RequestDescription.optional
23
+ attribute(:description, RequestDescription.default { RequestDescription.new })
24
24
  end
25
25
  end
26
26
  end
@@ -3,7 +3,6 @@
3
3
  module Cocina
4
4
  module Models
5
5
  class Title < Struct
6
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
7
6
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
8
7
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
8
  attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -24,8 +23,6 @@ module Cocina
24
23
  # Property model for indicating the encoding, standard, or syntax to which a value
25
24
  # conforms (e.g. RDA).
26
25
  attribute? :encoding, Standard.optional
27
- # Identifiers and URIs associated with the descriptive element.
28
- attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
29
26
  # Property model for indicating the vocabulary, authority, or other origin for a term,
30
27
  # code, or identifier.
31
28
  attribute? :source, Source.optional
@@ -40,6 +37,9 @@ module Cocina
40
37
  attribute? :valueLanguage, DescriptiveValueLanguage.optional
41
38
  # URL or other pointer to the location of the value of the descriptive element.
42
39
  attribute? :valueAt, Types::Strict::String
40
+ attribute :appliesTo, Types::Strict::Array.of(AppliesTo).default([].freeze)
41
+ # Identifiers and URIs associated with the descriptive element.
42
+ attribute :identifier, Types::Strict::Array.of(DescriptiveIdentifier).default([].freeze)
43
43
  end
44
44
  end
45
45
  end
@@ -12,7 +12,18 @@ module Cocina
12
12
  Thread.current[:top_level_validate] = validate unless Thread.current.key?(:top_level_validate)
13
13
 
14
14
  Validators::Validator.validate(self, attributes) if Thread.current[:top_level_validate]
15
- super(attributes, safe, &)
15
+
16
+ # Tracks that any Validatable construction triggered by `super` below is nested inside this one, so
17
+ # JsonSchemaValidator can skip it: this object's own schema check above already covers.
18
+ # See JsonSchemaValidator#validate.
19
+ # In practice, this avoids doing schema validation on a DRO / AdminPolicy / Collection and
20
+ # its nested Description.
21
+ Thread.current[:cocina_construction_depth] = Thread.current[:cocina_construction_depth].to_i + 1
22
+ begin
23
+ super(attributes, safe, &)
24
+ ensure
25
+ Thread.current[:cocina_construction_depth] -= 1
26
+ end
16
27
  ensure
17
28
  Thread.current[:top_level_validate] = nil
18
29
  end
@@ -20,7 +31,14 @@ module Cocina
20
31
 
21
32
  def new(*args)
22
33
  validate = args.first.delete(:validate) if args.present?
23
- new_model = super
34
+
35
+ Thread.current[:cocina_construction_depth] = Thread.current[:cocina_construction_depth].to_i + 1
36
+ begin
37
+ new_model = super
38
+ ensure
39
+ Thread.current[:cocina_construction_depth] -= 1
40
+ end
41
+
24
42
  Validators::Validator.validate(new_model.class, new_model) if validate || validate.nil?
25
43
  new_model
26
44
  end
@@ -11,6 +11,7 @@ module Cocina
11
11
  DescriptionRoleSourceCodeVisitorValidator,
12
12
  DescriptionNameSourceCodeVisitorValidator,
13
13
  DescriptionFormResourceTypeVisitorValidator,
14
+ DescriptionFormSourceCodeVisitorValidator,
14
15
  DescriptionLanguageCodeVisitorValidator,
15
16
  DescriptionSubjectSourceCodeVisitorValidator,
16
17
  DescriptionValuesVisitorValidator,
@@ -30,7 +31,7 @@ module Cocina
30
31
  def initialize(clazz, attributes, validators: VALIDATORS)
31
32
  @clazz = clazz
32
33
  @attributes = attributes
33
- @validators = validators.map(&:new)
34
+ @validators = meets_preconditions? ? validators.map(&:new) : []
34
35
  end
35
36
 
36
37
  def validate
@@ -18,7 +18,7 @@ module Cocina
18
18
  def initialize(clazz, attributes, validators: VALIDATORS)
19
19
  @clazz = clazz
20
20
  @attributes = attributes
21
- @validators = validators.map { |v| v.new(attributes) }
21
+ @validators = meets_preconditions? ? validators.map { |v| v.new(attributes) } : []
22
22
  end
23
23
 
24
24
  def validate
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cocina
4
+ module Models
5
+ module Validators
6
+ # Validates form.source.code values against form_source_codes.yml.
7
+ class DescriptionFormSourceCodeVisitorValidator < BaseDescriptionVisitorValidator
8
+ def validate!
9
+ return if error_paths.empty?
10
+
11
+ raise ValidationError, "Unrecognized form source codes in description: #{error_paths.join(', ')}"
12
+ end
13
+
14
+ def visit_hash(hash:, path:)
15
+ return unless form_entry_path?(path)
16
+
17
+ source_code = hash.dig(:source, :code)
18
+ return unless source_code
19
+
20
+ error_paths << "#{path_to_s(path)}.source.code (#{source_code})" unless valid_codes.include?(source_code.downcase)
21
+ end
22
+
23
+ private
24
+
25
+ def error_paths
26
+ @error_paths ||= []
27
+ end
28
+
29
+ def form_entry_path?(path)
30
+ path.length >= 2 && path[-1].is_a?(Integer) && path[-2].to_s == 'form'
31
+ end
32
+
33
+ # rubocop:disable Style/ClassVars
34
+ def valid_codes
35
+ @@valid_codes ||= YAML.load_file(::File.expand_path('../../../../form_source_codes.yml', __dir__)).to_set(&:downcase)
36
+ end
37
+ # rubocop:enable Style/ClassVars
38
+ end
39
+ end
40
+ end
41
+ end
@@ -25,6 +25,11 @@ module Cocina
25
25
 
26
26
  # @see #validate
27
27
  def self.validate(...)
28
+ # Skip when nested inside another Validatable's construction: the enclosing object's own
29
+ # schema check already covers this, so re-evaluating it here would be
30
+ # redundant. See Validatable#new.
31
+ return if Thread.current[:cocina_construction_depth].to_i.positive?
32
+
28
33
  new(...).validate
29
34
  end
30
35
 
@@ -67,7 +72,7 @@ module Cocina
67
72
  evaluation = self.class.validator_for(method_name).evaluate(attributes.as_json)
68
73
  return if evaluation.valid?
69
74
 
70
- raise ValidationError, "When validating #{method_name}: " + filtered_error_messages(evaluation).join(', ')
75
+ raise ValidationError, "When validating #{method_name}: " + filtered_error_messages(evaluation).join('; ')
71
76
  end
72
77
 
73
78
  private
@@ -90,7 +95,7 @@ module Cocina
90
95
  denoised.flat_map { |d| format_detail(d) }.uniq
91
96
  end
92
97
 
93
- # Removes two categories of cascade noise produced by `unevaluatedProperties: false`:
98
+ # Removes three categories of cascade noise from errors:
94
99
  #
95
100
  # 1. falseSchema errors — every `unevaluatedProperties: false` on a sub-path emits
96
101
  # a "False schema does not allow …" entry for each matched value. These are
@@ -100,8 +105,28 @@ module Cocina
100
105
  # is a known model attribute — these arise because allOf/$ref composition means
101
106
  # the validator can't prove top-level properties were "evaluated", even though
102
107
  # they are valid model fields.
108
+ #
109
+ # 3. anyOf branch noise — when each branch requires a different property, all failing
110
+ # branches emit a separate "required" error at the same path. Collapse into one
111
+ # "needs to include at least one of: …" message.
103
112
  def denoise(details)
104
- details.reject { |d| false_schema_noise?(d) || root_unevaluated_noise?(d) }
113
+ filtered = details.reject { |detail| false_schema_noise?(detail) || root_unevaluated_noise?(detail) }
114
+ collapse_anyof_required(filtered)
115
+ end
116
+
117
+ def collapse_anyof_required(details)
118
+ # instanceLocation is the input data that failed. Group errors for each failing data location.
119
+ details.group_by { |detail| detail[:instanceLocation] }.flat_map do |loc, group|
120
+ # splits errors into "required" anyOf errors and others (minItems, unevaluatedProperties)
121
+ required, others = group.partition { |detail| detail[:errors].keys == ['required'] }
122
+ next group if required.size <= 1
123
+
124
+ # Extract property name from "required" messages such as, '"url" is a required property'
125
+ props = required.filter_map { |detail| detail[:errors]['required'][/"([^"]+)"/, 1] }.uniq
126
+ # Put the data path at the start of the message; blank instanceLocation so format_detail won't also append it
127
+ msg = "#{loc} needs to include at least one of the following: #{props.join(', ')}"
128
+ others + [required.first.merge(instanceLocation: '', errors: { 'required' => msg })]
129
+ end
105
130
  end
106
131
 
107
132
  def false_schema_noise?(detail)
@@ -120,12 +145,32 @@ module Cocina
120
145
  unexpected.all? { |prop| known_root_properties.include?(prop) }
121
146
  end
122
147
 
148
+ # Formats error details hash into more user-friendly messages
149
+ # A detail can carry multiple errors (one per JSON Schema keyword), so this returns an array.
150
+ # minItems errors get a dedicated formatter; everything else uses the generic one.
123
151
  def format_detail(detail)
124
152
  loc = detail[:instanceLocation]
125
- detail[:errors].map do |_keyword, message|
126
- loc.empty? ? message : "#{message} at #{loc}"
153
+ detail[:errors].map do |keyword, message|
154
+ keyword == 'minItems' ? format_min_items(message, loc) : format_generic(message, loc)
127
155
  end
128
156
  end
157
+
158
+ # Rewrites messages regarding minItems validation
159
+ # "[] has less than 1 item at /event/0/date " is rewritten to "/event/0/date is empty but should have at least 1 item"
160
+ # Arrays with items but fewer than the minimum will be rewritten to "/event/0/date should have at least 2 items"
161
+ def format_min_items(message, loc)
162
+ # extract minimum number of items from message
163
+ min = message[/less than (\d+)/, 1] || '1'
164
+ # pluralize "item" when appropriate
165
+ items = min == '1' ? 'item' : 'items'
166
+ # determine if the message is about an empty array or an non-empty array without enough items
167
+ prefix = message.start_with?('[]') ? "#{loc} is empty but" : loc.to_s
168
+ "#{prefix} should have at least #{min} #{items}"
169
+ end
170
+
171
+ def format_generic(message, loc)
172
+ loc.empty? ? message : "#{message} at #{loc}"
173
+ end
129
174
  end
130
175
  end
131
176
  end
@@ -16,12 +16,22 @@ module Cocina
16
16
  ].freeze
17
17
 
18
18
  def self.validate(clazz, attributes, validators: VALIDATORS)
19
- # This gets rid of nested model objects.
20
- attributes_hash = attributes.to_h.deep_transform_values do |value|
19
+ hash = attributes_hash(attributes)
20
+ validators.each { |validator| validator.validate(clazz, hash) }
21
+ end
22
+
23
+ # @return [Hash] attributes with any embedded Cocina model instances flattened to hashes
24
+ def self.attributes_hash(attributes)
25
+ # Dry::Struct#to_h is already fully recursive (Dry::Struct::Hashify flattens nested
26
+ # structs all the way down), so a Cocina model instance's #to_h has no embedded
27
+ # Cocina model instances left to flatten and the deep walk below would be a no-op.
28
+ return attributes.to_h.with_indifferent_access if attributes.class.name.starts_with?('Cocina::Models')
29
+
30
+ attributes.to_h.deep_transform_values do |value|
21
31
  value.class.name.starts_with?('Cocina::Models') ? value.to_h : value
22
32
  end.with_indifferent_access
23
- validators.each { |validator| validator.validate(clazz, attributes_hash) }
24
33
  end
34
+ private_class_method :attributes_hash
25
35
 
26
36
  def self.deep_transform_values(object, ...)
27
37
  case object
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.127.0'
5
+ VERSION = '0.129.0'
6
6
  end
7
7
  end
@@ -48,7 +48,6 @@ module Cocina
48
48
  type: Cocina::Models::ObjectType.object,
49
49
  id: 'druid:bc234fg5678',
50
50
  version: 1,
51
- label: 'factory DRO label',
52
51
  title: 'factory DRO title',
53
52
  source_id: 'sul:1234',
54
53
  admin_policy_id: 'druid:hv992ry2431'
@@ -60,7 +59,6 @@ module Cocina
60
59
  type: Cocina::Models::ObjectType.collection,
61
60
  id: 'druid:bb222ff5555',
62
61
  version: 1,
63
- label: 'factory collection label',
64
62
  title: 'factory collection title',
65
63
  admin_policy_id: 'druid:hv992ry2431',
66
64
  source_id: 'sulcollection:1234'
@@ -72,7 +70,6 @@ module Cocina
72
70
  type: Cocina::Models::ObjectType.admin_policy,
73
71
  id: 'druid:cb432gf8765',
74
72
  version: 1,
75
- label: 'factory APO label',
76
73
  title: 'factory APO title',
77
74
  admin_policy_id: 'druid:hv992ry2431',
78
75
  agreement_id: 'druid:hp308wm0436'
@@ -97,12 +94,11 @@ module Cocina
97
94
  end
98
95
 
99
96
  # rubocop:disable Metrics/ParameterLists
100
- def self.build_request_dro_properties(type:, version:, label:, title:, source_id:, admin_policy_id:,
97
+ def self.build_request_dro_properties(type:, version:, title:, source_id:, admin_policy_id:,
101
98
  barcode: nil, catkeys: [], folio_instance_hrids: [], collection_ids: [])
102
99
  {
103
100
  type: type,
104
101
  version: version,
105
- label: label,
106
102
  access: {},
107
103
  administrative: { hasAdminPolicy: admin_policy_id },
108
104
  description: {
@@ -143,12 +139,11 @@ module Cocina
143
139
  end
144
140
 
145
141
  # rubocop:disable Metrics/ParameterLists
146
- def self.build_request_collection_properties(type:, version:, label:, title:, admin_policy_id:, source_id: nil,
142
+ def self.build_request_collection_properties(type:, version:, title:, admin_policy_id:, source_id: nil,
147
143
  catkeys: [], folio_instance_hrids: [])
148
144
  {
149
145
  type: type,
150
146
  version: version,
151
- label: label,
152
147
  access: {},
153
148
  administrative: { hasAdminPolicy: admin_policy_id },
154
149
  description: {
@@ -204,7 +199,7 @@ module Cocina
204
199
  end
205
200
 
206
201
  # rubocop:disable Metrics/ParameterLists
207
- def self.build_request_admin_policy_properties(type:, version:, label:, title:,
202
+ def self.build_request_admin_policy_properties(type:, version:, title:,
208
203
  admin_policy_id:, agreement_id:,
209
204
  use_statement: nil, copyright: nil, license: nil,
210
205
  registration_workflow: nil, collections_for_registration: nil,
@@ -212,7 +207,6 @@ module Cocina
212
207
  {
213
208
  type: type,
214
209
  version: version,
215
- label: label,
216
210
  administrative: {
217
211
  hasAdminPolicy: admin_policy_id,
218
212
  hasAgreement: agreement_id,