cocina-models 0.117.0 → 0.118.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2609452ca800a04e29d10e8412138a6a537fb1808b5555b25948962651754037
4
- data.tar.gz: 355fb239388276b6f13e514b97a2b7cf30a9467674a59dfab0b87ce159b187f1
3
+ metadata.gz: ee0ad9dfc1b1845db07b8d94510214d98be20b7d732e0909dfe1935c046e9a34
4
+ data.tar.gz: 60f04cbd3e7531c45357c546d490851b8b781f0224468c2d2c81d9e3e550575f
5
5
  SHA512:
6
- metadata.gz: 9b5b6684d1dc9c477a8fe215b9a7312fc20160861413b9bc1bdf3a3e09f97e7e129549996831b4d04609074e0bfa7fbdba05c3f2621dc71e5d814d2fd91ba71e
7
- data.tar.gz: 9874c91f51dd30606fe87d9d848f51adc3ff91785d4d7b604bab667ea9869357453c9a76d7aff54332f4bf676c1c2836ec3677d864777130bbf50fcd49423a1f
6
+ metadata.gz: 70072f8f40350f2341e230f2cee198c72872b418eda5e1682e7ab1cdb7fac5c5121386a6e53d3d0c3995139b80797ec51783f0630a9844d948cc8fd79afa7f19
7
+ data.tar.gz: c56237be3e22069ade32d6ad81d9a989c78759dc9d3ff8a99150709958da55f942707670aa2b7aad05f35d9dcbc9e9d073a01f47079f8deefbb7d42902725a77
data/.circleci/config.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  version: 2.1
2
2
  orbs:
3
- ruby-rails: sul-dlss/ruby-rails@4.7.0
3
+ ruby-rails: sul-dlss/ruby-rails@4.8.0
4
4
  workflows:
5
5
  build:
6
6
  jobs:
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- cocina-models (0.117.0)
4
+ cocina-models (0.118.0)
5
5
  activesupport
6
6
  deprecation
7
7
  dry-struct (~> 1.0)
@@ -84,7 +84,7 @@ GEM
84
84
  prism (>= 1.3.0)
85
85
  rdoc (>= 4.0.0)
86
86
  reline (>= 0.4.2)
87
- json (2.19.4)
87
+ json (2.19.5)
88
88
  json_schemer (2.5.0)
89
89
  bigdecimal
90
90
  hana (~> 1.3)
@@ -95,13 +95,13 @@ GEM
95
95
  language_server-protocol (3.17.0.5)
96
96
  lint_roller (1.1.0)
97
97
  logger (1.7.0)
98
- minitest (6.0.5)
98
+ minitest (6.0.6)
99
99
  drb (~> 2.0)
100
100
  prism (~> 1.5)
101
- multi_json (1.20.1)
102
- nokogiri (1.19.2-arm64-darwin)
101
+ multi_json (1.21.1)
102
+ nokogiri (1.19.3-arm64-darwin)
103
103
  racc (~> 1.4)
104
- nokogiri (1.19.2-x86_64-linux-gnu)
104
+ nokogiri (1.19.3-x86_64-linux-gnu)
105
105
  racc (~> 1.4)
106
106
  optimist (3.2.1)
107
107
  parallel (2.1.0)
@@ -142,7 +142,7 @@ GEM
142
142
  rspec-support (3.13.7)
143
143
  rspec_junit_formatter (0.6.0)
144
144
  rspec-core (>= 2, < 4, != 2.12.0)
145
- rubocop (1.86.1)
145
+ rubocop (1.86.2)
146
146
  json (~> 2.3)
147
147
  language_server-protocol (~> 3.17.0.2)
148
148
  lint_roller (~> 1.1.0)
@@ -172,10 +172,10 @@ GEM
172
172
  simplecov_json_formatter (0.1.4)
173
173
  simpleidn (0.2.3)
174
174
  stringio (3.2.0)
175
- super_diff (0.18.0)
176
- attr_extras (>= 6.2.4)
177
- diff-lcs
178
- patience_diff
175
+ super_diff (0.19.0)
176
+ attr_extras (>= 6.2.4, < 8)
177
+ diff-lcs (~> 1.5)
178
+ patience_diff (~> 1.2)
179
179
  thor (1.5.0)
180
180
  tsort (0.2.0)
181
181
  tzinfo (2.0.6)
@@ -184,7 +184,7 @@ GEM
184
184
  unicode-emoji (~> 4.1)
185
185
  unicode-emoji (4.2.0)
186
186
  uri (1.1.1)
187
- zeitwerk (2.7.5)
187
+ zeitwerk (2.8.2)
188
188
 
189
189
  PLATFORMS
190
190
  arm64-darwin
@@ -209,7 +209,8 @@ CHECKSUMS
209
209
  attr_extras (7.1.0) sha256=d96fc9a9dd5d85ba2d37762440a816f840093959ae26bb90da994c2d9f1fc827
210
210
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
211
211
  bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
212
- cocina-models (0.117.0)
212
+ bundler (4.0.12) sha256=7f8b757d28dfb636e7b24fba2344ac6dd13b5b24f4b46d62573d483f211825ac
213
+ cocina-models (0.118.0)
213
214
  concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
214
215
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
215
216
  date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
@@ -231,16 +232,16 @@ CHECKSUMS
231
232
  ice_nine (0.11.2) sha256=5d506a7d2723d5592dc121b9928e4931742730131f22a1a37649df1c1e2e63db
232
233
  io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
233
234
  irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
234
- json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
235
+ json (2.19.5) sha256=218a18553e4801d579ca7e0f5bc72bafd776d7397238a1fb4e74db5b0a812c59
235
236
  json_schemer (2.5.0) sha256=2f01fb4cce721a4e08dd068fc2030cffd0702a7f333f1ea2be6e8991f00ae396
236
237
  jsonpath (1.1.5) sha256=29f70467193a2dc93ab864ec3d3326d54267961acc623f487340eb9c34931dbe
237
238
  language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
238
239
  lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
239
240
  logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
240
- minitest (6.0.5) sha256=f007d7246bf4feea549502842cd7c6aba8851cdc9c90ba06de9c476c0d01155c
241
- multi_json (1.20.1) sha256=2f3934e805cc45ef91b551a1f89d0e9191abd06a5e04a2ef09a6a036c452ca6d
242
- nokogiri (1.19.2-arm64-darwin) sha256=58d8ea2e31a967b843b70487a44c14c8ba1866daa1b9da9be9dbdf1b43dee205
243
- nokogiri (1.19.2-x86_64-linux-gnu) sha256=fa8feca882b73e871a9845f3817a72e9734c8e974bdc4fbad6e4bc6e8076b94f
241
+ minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1
242
+ multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
243
+ nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42
244
+ nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976
244
245
  optimist (3.2.1) sha256=8cf8a0fd69f3aa24ab48885d3a666717c27bc3d9edd6e976e18b9d771e72e34e
245
246
  parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356
246
247
  parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54
@@ -261,7 +262,7 @@ CHECKSUMS
261
262
  rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
262
263
  rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
263
264
  rspec_junit_formatter (0.6.0) sha256=40dde674e6ae4e6cc0ff560da25497677e34fefd2338cc467a8972f602b62b15
264
- rubocop (1.86.1) sha256=44415f3f01d01a21e01132248d2fd0867572475b566ca188a0a42133a08d4531
265
+ rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d
265
266
  rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035
266
267
  rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d
267
268
  rubocop-rspec (3.9.0) sha256=8fa70a3619408237d789aeecfb9beef40576acc855173e60939d63332fdb55e2
@@ -272,14 +273,14 @@ CHECKSUMS
272
273
  simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
273
274
  simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
274
275
  stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
275
- super_diff (0.18.0) sha256=9f5e77464fa75150619f7783174fbbe1bbac50a1eaf157cd39ad5584b0eac142
276
+ super_diff (0.19.0) sha256=c35fc1c0daa223d67b203fe3fb49a6cfd67850a53920319565c3c654e03ec902
276
277
  thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
277
278
  tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
278
279
  tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
279
280
  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
280
281
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
281
282
  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
282
- zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
283
+ zeitwerk (2.8.2) sha256=7212a61311083c604184b1ea2574b9aa05cd14f855a0841c06985cabe9181d12
283
284
 
284
285
  BUNDLED WITH
285
- 4.0.10
286
+ 4.0.12
data/README.md CHANGED
@@ -16,6 +16,10 @@ For more about the model for description see https://consul.stanford.edu/display
16
16
 
17
17
  The [schema.json](schema.json) can also be viewed via JSON HERO: https://jsonhero.io/j/rynNH3NLBhcf and DOR Services App's openapi.yml: https://sul-dlss.github.io/dor-services-app/
18
18
 
19
+ ### Schema Design
20
+
21
+ Our JSON Schema uses *terminal & mixin* composition strategy so we can enforce deep validation without running into **allOf** composition pitfalls. Terminal schemas (e.g., **DRO**, **Collection**, **AdminPolicy**, their **WithMetadata** variants, and **Request** variants) are the validation boundaries and generally set the `unevaluatedProperties: false` property. Reusable mixins (e.g., **DROMixin**, **CollectionMixin**, **AdminPolicyMixin**, and **AccessMixin**) are intentionally open and are composed into terminal schemas via **allOf** or **oneOf**, so these schemas **may not** take advantage of `unevaluatedProperties` to prevent unexpected properties.
22
+
19
23
  ## Configuration
20
24
 
21
25
  Set the PURL url base:
@@ -121,9 +125,7 @@ Before you release a major or minor change, think about if this release will inc
121
125
  If unsure, ask the team or ask for help to just run the validation report anyway (as described above).
122
126
 
123
127
  ### Partial release process
124
- NOTE: If dependency updates are about to be released, you have the option of shortening the process and stopping after Step 3. This is because Steps 4 onwards will be taken care of by the regular dependency updates process (basically the updating of cocina-models, dor-services-client and sdr-client as needed in the rest of the associated apps). You still do need to manually bump some gems and the pinned version of cocina-models in the directly coupled apps and get those PRs approved and merged, as described in Steps 1-3 below.
125
-
126
- IMPORTANT: If you do opt to skip steps 4 onward, you should NOT merge the dor-services-app and sdr-api PRs you created in step 3 until you are ready to finish the dependency updates process. You can have them reviewed and approved, but if you merge, you will greatly increase the risk of issues if the main branch of DSA or sdr-api are deployed after steps 1-3 are complete but before the rest of the apps are updated to use the new cocina-models via regular dependency updates. The fix for this is to either roll back DSA and sdr-api to the previous release tag, or proceed forwards with step 4-5.
128
+ NOTE: If dependency updates are about to be released, you have the option of shortening the process and stopping after Step 2. This is because Steps 3 onwards will be taken care of by the regular dependency updates process (basically the updating of cocina-models, dor-services-client and sdr-client as needed in the rest of the associated apps). You still do need to manually bump some gems and get those PRs approved and merged, as described in Steps 1-2 below.
127
129
 
128
130
  ### Step 0: Share intent to change the models
129
131
 
@@ -160,8 +162,6 @@ Perform `bundle update --conservative cocina-models dor-services-client` in the
160
162
 
161
163
  Get the directly coupled services PRs merged before the deploy in step 5.
162
164
 
163
- See the IMPORTANT note above about the timing of merging these PRs if you are waiting for dependency updates to make the updates to other dependent applications.
164
-
165
165
  ### Step 4: Update other dependent applications
166
166
 
167
167
  All applications that use cocina-models should be updated and released at the same time. "Cocina Level 2" describes this set of updates. The applications that use cocina-models are those in [this list](https://github.com/sul-dlss/access-update-scripts/blob/master/infrastructure/projects.yml) that are NOT marked with `cocina_level2: false`.
@@ -102,8 +102,10 @@ module Cocina
102
102
  end
103
103
 
104
104
  def schema_for(schema_name, lite: false)
105
+ return if schema_name.ends_with?('Mixin')
106
+
105
107
  schema_doc = schemas[schema_name]
106
- return nil if schema_doc.nil?
108
+ return if schema_doc.nil?
107
109
 
108
110
  case schema_doc.type
109
111
  when 'object'
@@ -5,21 +5,25 @@ module Cocina
5
5
  # Class for generating from a JSON schema
6
6
  class Schema < SchemaBase
7
7
  def schema_properties
8
- @schema_properties ||= (properties + all_of_properties + one_of_properties).uniq(&:key)
8
+ @schema_properties ||= (
9
+ schema_properties_for(schema_doc) +
10
+ all_of_properties_for(schema_doc) +
11
+ one_of_properties_for(schema_doc)
12
+ ).uniq(&:key)
9
13
  end
10
14
 
11
- VALIDATE_TYPES = %w[DRO RequestDRO DROWithMetadata Collection RequestCollection CollectionWithMetadata AdminPolicy
12
- RequestAdminPolicy AdminPolicyWithMetadata Description RequestDescription].freeze
15
+ VALIDATABLE_TYPES = %w[DRO RequestDRO DROWithMetadata Collection RequestCollection CollectionWithMetadata AdminPolicy
16
+ RequestAdminPolicy AdminPolicyWithMetadata Description RequestDescription].freeze
13
17
 
14
18
  def generate
15
19
  <<~RUBY
16
20
  # frozen_string_literal: true
17
21
 
18
22
  module Cocina
19
- module Models#{' '}
23
+ module Models
20
24
  #{preamble}class #{name} < Struct
21
25
 
22
- #{validate}
26
+ #{validatable}
23
27
  #{types}
24
28
 
25
29
  #{model_attributes}
@@ -65,68 +69,41 @@ module Cocina
65
69
  RUBY
66
70
  end
67
71
 
68
- def validate
69
- return '' unless validatable?
72
+ def validatable
73
+ return '' if VALIDATABLE_TYPES.exclude?(name)
70
74
 
71
75
  <<~RUBY
72
76
  include Validatable
73
77
  RUBY
74
78
  end
75
79
 
76
- def validatable?
77
- VALIDATE_TYPES.include?(name) && !lite
78
- end
79
-
80
- def properties
81
- schema_properties_for(schema_doc)
82
- end
83
-
84
- def all_of_properties
85
- all_of_properties_for(schema_doc)
86
- end
87
-
88
- def one_of_properties
89
- one_of_properties_for(schema_doc)
90
- end
91
-
92
80
  def all_of_properties_for(doc)
93
- return [] if doc.all_of.nil?
94
-
95
- doc.all_of.map do |all_of_schema|
96
- # All of for this + recurse
81
+ Array(doc.all_of).flat_map do |all_of_schema|
97
82
  schema_properties_for(all_of_schema) +
98
83
  all_of_properties_for(all_of_schema) +
99
84
  one_of_properties_for(all_of_schema)
100
- end.flatten
85
+ end
101
86
  end
102
87
 
103
88
  def one_of_properties_for(doc)
104
- return [] if doc.one_of.nil?
105
-
106
- # All properties must be objects.
107
- raise 'All properties for oneOf must be objects' unless doc.one_of.all? { |schema| schema.type == 'object' }
108
-
109
- doc.one_of.flat_map do |one_of_doc|
110
- one_of_doc.properties.map do |key, properties_doc|
111
- property_class_for(properties_doc).new(properties_doc,
112
- key: key,
113
- # The property does less validation because may vary between
114
- # different oneOf schemas. Validation is still performed
115
- # by JSON Schema.
116
- relaxed: true,
117
- parent: self,
118
- schemas: schemas)
119
- end
89
+ Array(doc.one_of).flat_map do |one_of_schema|
90
+ schema_properties_for(one_of_schema, relaxed: true) +
91
+ all_of_properties_for(one_of_schema) +
92
+ one_of_properties_for(one_of_schema)
120
93
  end
121
94
  end
122
95
 
123
- def schema_properties_for(doc)
96
+ def schema_properties_for(doc, relaxed: nil)
97
+ relax_all_properties = relaxed
98
+
124
99
  Array(doc.properties).map do |key, properties_doc|
125
100
  clazz = property_class_for(properties_doc)
101
+ relaxed = relax_all_properties.nil? ? lite && clazz != SchemaValue : relax_all_properties
102
+
126
103
  clazz.new(properties_doc,
127
104
  key: key,
128
105
  required: doc.required&.include?(key),
129
- relaxed: lite && clazz != SchemaValue,
106
+ relaxed: relaxed,
130
107
  nullable: Array(properties_doc.type).include?('null'),
131
108
  parent: self,
132
109
  schemas: schemas)
@@ -4,6 +4,7 @@ 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)
7
8
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
8
9
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
9
10
  attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -40,7 +41,6 @@ module Cocina
40
41
  attribute? :valueLanguage, DescriptiveValueLanguage.optional
41
42
  # URL or other pointer to the location of the value of the descriptive element.
42
43
  attribute? :valueAt, Types::Strict::String
43
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
44
44
  end
45
45
  end
46
46
  end
@@ -3,17 +3,6 @@
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)
17
6
  # The human readable copyright statement that applies
18
7
  # example: Copyright World Trade Organization
19
8
  attribute? :copyright, Copyright.optional
@@ -27,6 +16,17 @@ module Cocina
27
16
  # The license governing reuse of the DRO. Should be an IRI for known licenses (i.e.
28
17
  # CC, RightsStatement.org URI, etc.).
29
18
  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
@@ -3,6 +3,15 @@
3
3
  module Cocina
4
4
  module Models
5
5
  class Embargo < Struct
6
+ # Date when the Collection is released from an embargo.
7
+ # example: 2029-06-22T07:00:00.000+00:00
8
+ attribute :releaseDate, Types::Params::DateTime
9
+ # The human readable use and reproduction statement that applies
10
+ # example: Property rights reside with the repository. Literary rights reside with
11
+ # the creators of the documents or their heirs. To obtain permission to publish or
12
+ # reproduce, please contact the Public Services Librarian of the Dept. of Special Collections
13
+ # (http://library.stanford.edu/spc).
14
+ attribute? :useAndReproductionStatement, UseAndReproductionStatement.optional
6
15
  # Access level.
7
16
  # Validation of this property is relaxed. See the schema.json for full validation.
8
17
  attribute? :view, Types::Strict::String.optional.default('dark')
@@ -14,15 +23,6 @@ module Cocina
14
23
  attribute? :location, Types::Strict::String.optional
15
24
  # Validation of this property is relaxed. See the schema.json for full validation.
16
25
  attribute? :controlledDigitalLending, Types::Strict::Bool.optional.default(false)
17
- # Date when the Collection is released from an embargo.
18
- # example: 2029-06-22T07:00:00.000+00:00
19
- attribute :releaseDate, Types::Params::DateTime
20
- # The human readable use and reproduction statement that applies
21
- # example: Property rights reside with the repository. Literary rights reside with
22
- # the creators of the documents or their heirs. To obtain permission to publish or
23
- # reproduce, please contact the Public Services Librarian of the Dept. of Special Collections
24
- # (http://library.stanford.edu/spc).
25
- attribute? :useAndReproductionStatement, UseAndReproductionStatement.optional
26
26
  end
27
27
  end
28
28
  end
@@ -394,13 +394,14 @@ module Cocina
394
394
  end
395
395
 
396
396
  def blank_ng_xml
397
- Nokogiri::XML(<<~XML
398
- <mods xmlns="http://www.loc.gov/mods/v3"#{' '}
399
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"#{' '}
400
- version="3.6"#{' '}
401
- xsi:schemaLocation="http://www.loc.gov/mods/v3 http://www.loc.gov/standards/mods/v3/mods-3-6.xsd" />
402
- XML
403
- )
397
+ Nokogiri::XML(
398
+ <<~XML
399
+ <mods xmlns="http://www.loc.gov/mods/v3"
400
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
401
+ version="3.6"
402
+ xsi:schemaLocation="http://www.loc.gov/mods/v3 http://www.loc.gov/standards/mods/v3/mods-3-6.xsd" />
403
+ XML
404
+ )
404
405
  end
405
406
  end
406
407
  end
@@ -3,6 +3,7 @@
3
3
  module Cocina
4
4
  module Models
5
5
  class Title < Struct
6
+ attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
6
7
  attribute :structuredValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
7
8
  attribute :parallelValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
8
9
  attribute :groupedValue, Types::Strict::Array.of(DescriptiveValue).default([].freeze)
@@ -39,7 +40,6 @@ module Cocina
39
40
  attribute? :valueLanguage, DescriptiveValueLanguage.optional
40
41
  # URL or other pointer to the location of the value of the descriptive element.
41
42
  attribute? :valueAt, Types::Strict::String
42
- attribute :appliesTo, Types::Strict::Array.of(DescriptiveBasicValue).default([].freeze)
43
43
  end
44
44
  end
45
45
  end
@@ -3,39 +3,172 @@
3
3
  module Cocina
4
4
  module Models
5
5
  module Validators
6
- # Perform validation against JSON schema
6
+ # Validates Cocina model instances against the JSON schema.
7
+ #
8
+ # The schema uses OpenAPI 3.1.0 conventions with `unevaluatedProperties: false` to enforce
9
+ # strict validation. However, when schemas use `allOf` with `$ref` (which is common for
10
+ # composing mixins), json_schemer reports cascaded unevaluatedProperties errors that are
11
+ # not actionable.
12
+ #
13
+ # For example, if a schema has:
14
+ # AdminPolicy:
15
+ # allOf:
16
+ # - $ref: '#/$defs/AdminPolicyMixin' # defines properties
17
+ # unevaluatedProperties: false
18
+ #
19
+ # And an unexpected property appears at `/administrative/releaseTags`, json_schemer will
20
+ # report both:
21
+ # - The actual error at `/administrative/releaseTags` (actionable)
22
+ # - Cascaded errors for every known root property like `/label`, `/type` (noise)
23
+ #
24
+ # This happens because `unevaluatedProperties` semantics with `allOf`/$ref can be
25
+ # ambiguous. See https://github.com/davishmcclurg/json_schemer/issues/157
26
+ #
27
+ # To reduce noise while preserving actionable errors, this validator applies de-noising
28
+ # by keeping only:
29
+ #
30
+ # 1. All errors for nested properties (depth > 1)
31
+ # 2. Root-level errors for genuinely unknown properties (not in the model schema)
32
+ #
33
+ # This filters out cascaded root-level false positives without hiding real nested issues.
7
34
  class JsonSchemaValidator
8
- def self.validate(clazz, attributes)
9
- return unless clazz.name
35
+ SCHEMA_PATH = ::File.expand_path('../../../../schema.json', __dir__)
10
36
 
11
- method_name = clazz.name.split('::').last
37
+ # @see #validate
38
+ def self.validate(...)
39
+ new(...).validate
40
+ end
41
+
42
+ # @param clazz [Class] the Cocina model class being validated (e.g., Cocina::Models::DRO)
43
+ # @param attributes [Hash] the attributes of the model instance being validated
44
+ def initialize(clazz, attributes)
45
+ @clazz = clazz
46
+ @attributes = attributes
47
+ end
12
48
 
13
- attributes['cocinaVersion'] = Cocina::Models::VERSION if %w[DRO RequestDRO AdminPolicy RequestAdminPolicy Collection RequestCollection DROWithMetadata].include? method_name
49
+ # Validates attributes against the Cocina model schema.
50
+ #
51
+ # Injects the cocinaVersion if the model includes it as an attribute, then validates
52
+ # the attributes against the schema definition for this model. De-noises
53
+ # unevaluatedProperties errors before raising a ValidationError.
54
+ #
55
+ # @return [NilClass] returns nil if validation passes
56
+ # @raise [ValidationError] if validation fails, with a de-noised error message
57
+ def validate
58
+ attributes['cocinaVersion'] = Cocina::Models::VERSION if clazz.attribute_names.include?(:cocinaVersion)
14
59
 
15
60
  errors = schema.ref("#/$defs/#{method_name}").validate(attributes.as_json).to_a
16
- return unless errors.any?
61
+ return if errors.empty?
17
62
 
18
- raise ValidationError, "When validating #{method_name}: " + errors.map { |e| e['error'] }.uniq.join(', ')
63
+ raise ValidationError, "When validating #{method_name}: " + filtered_error_messages(errors).join(', ')
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :clazz, :attributes
69
+
70
+ # @return [JSONSchemer::Schema]
71
+ def schema
72
+ @schema ||= JSONSchemer.schema(document)
19
73
  end
20
74
 
21
75
  # @return [Hash] a hash representation of the schema.json document
22
- def self.document
76
+ def document
23
77
  @document ||= begin
24
- file_content = ::File.read(schema_path)
78
+ file_content = ::File.read(SCHEMA_PATH)
25
79
  JSON.parse(file_content)
26
80
  end
27
81
  end
28
82
 
29
- # @return [JSONSchemer::Schema]
30
- def self.schema
31
- @schema ||= JSONSchemer.schema(document)
83
+ # @return [String] the method name derived from the class name
84
+ def method_name
85
+ @method_name ||= clazz.name.split('::').last
86
+ end
87
+
88
+ # @return [Array<String>] list of known root property names for the model class
89
+ def known_root_properties
90
+ @known_root_properties ||= clazz.attribute_names.map(&:to_s)
32
91
  end
33
- private_class_method :schema
34
92
 
35
- def self.schema_path
36
- ::File.expand_path('../../../../schema.json', __dir__)
93
+ # Filters and de-duplicates error messages.
94
+ #
95
+ # Applies unevaluatedProperties de-noising to reduce cascaded false-positive errors,
96
+ # then extracts the error message strings and removes duplicates.
97
+ #
98
+ # @param errors [Array<Hash>] errors returned by json_schemer validator
99
+ # @return [Array<String>] de-noised, unique error message strings
100
+ def filtered_error_messages(errors)
101
+ denoised_errors = filter_unevaluated_property_noise(errors)
102
+ denoised_errors.map { |error| error['error'] }.uniq
103
+ end
104
+
105
+ # Filters out cascaded unevaluatedProperties errors while preserving actionable ones.
106
+ #
107
+ # When unevaluatedProperties errors are present, this method keeps:
108
+ # - All non-unevaluatedProperties errors (unchanged)
109
+ # - All unevaluatedProperties errors for nested properties (depth > 1)
110
+ # - Root-level unevaluatedProperties errors for genuinely unknown properties
111
+ #
112
+ # It discards:
113
+ # - Root-level unevaluatedProperties errors for known model attributes
114
+ #
115
+ # This filtering leverages the class attribute schema to distinguish between
116
+ # cascaded noise (root-level disallowed errors for known properties) and genuine
117
+ # issues (unexpected properties at any depth).
118
+ #
119
+ # @param errors [Array<Hash>] errors from json_schemer validator
120
+ # @return [Array<Hash>] filtered error hashes
121
+ def filter_unevaluated_property_noise(errors)
122
+ unevaluated_errors = errors.select { |error| unevaluated_property_error?(error) }
123
+ return errors if unevaluated_errors.empty?
124
+
125
+ # Keep non-unevaluated errors and filtered unevaluated errors
126
+ non_unevaluated = errors.reject { |error| unevaluated_property_error?(error) }
127
+ filtered_unevaluated = unevaluated_errors.select do |error|
128
+ depth = pointer_depth(error['data_pointer'])
129
+ # Keep if: no depth data, nested path, or unknown root property
130
+ depth.nil? || depth > 1 || root_unknown_property_error?(error)
131
+ end
132
+ non_unevaluated + filtered_unevaluated
133
+ end
134
+
135
+ # Checks if an error represents an unknown property at the root level.
136
+ #
137
+ # A root-level error is actionable only if the property name is not in the model's
138
+ # defined attributes. This distinguishes between genuine unexpected properties and
139
+ # cascaded noise from allOf/$ref evaluation.
140
+ #
141
+ # @param error [Hash] json_schemer error hash
142
+ # @return [Boolean] true if error is for a root-level unknown property
143
+ def root_unknown_property_error?(error)
144
+ return false unless pointer_depth(error['data_pointer']) == 1
145
+
146
+ property_name = error['data_pointer'].split('/').last
147
+ !known_root_properties.include?(property_name)
148
+ end
149
+
150
+ # Identifies errors related to unevaluatedProperties violations.
151
+ #
152
+ # @param error [Hash] json_schemer error hash
153
+ # @return [Boolean] true if error is an unevaluatedProperties violation
154
+ def unevaluated_property_error?(error)
155
+ error['schema_pointer']&.end_with?('/unevaluatedProperties') ||
156
+ error['error']&.include?('disallowed unevaluated property')
157
+ end
158
+
159
+ # Calculates the depth of a JSON pointer path.
160
+ #
161
+ # A root-level property has depth 1 (e.g., `/label`), a nested property has depth > 1
162
+ # (e.g., `/administrative/releaseTags` has depth 2).
163
+ #
164
+ # @param pointer [String, nil] JSON pointer path (e.g., `/administrative/releaseTags`)
165
+ # @return [Integer, nil] path depth, or nil if pointer is nil
166
+ def pointer_depth(pointer)
167
+ return if pointer.nil?
168
+ return 0 if pointer.empty?
169
+
170
+ pointer.split('/').reject(&:empty?).length
37
171
  end
38
- private_class_method :schema_path
39
172
  end
40
173
  end
41
174
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cocina
4
4
  module Models
5
- VERSION = '0.117.0'
5
+ VERSION = '0.118.0'
6
6
  end
7
7
  end