lutaml-jsonschema 0.1.16 → 0.1.17

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: 4a225cce2d635b5a3d8b1376087e61b6f20e319ed84b34bc543812279bb7c6f2
4
- data.tar.gz: 394d28b298c43654eb7d6f6614b310ef31311a0e51d6a537f41b70e947010fa6
3
+ metadata.gz: a510965cb5b2a1815007d9e5ae6dbd587d4ac431b6b5c600324f855e33443ca5
4
+ data.tar.gz: 74c3d962eaabbe06518b32d4366b4ca4f017cef900cc3ce6610c8a3ebbcc705a
5
5
  SHA512:
6
- metadata.gz: 6f1f62960e78d9e07ac390fc37ab7c8e0853cbaeaefdaa12843bc41ceb17aecfdaa2193dcf26e382034008b4377fcedfad4094648ce858a611b431cfe78ead7c
7
- data.tar.gz: 9f1e1c38bd3335511264282a1e5d49cb74b4bb850a43ee58355fb64b422efb68415b778eda4540a79fbaf933bc265a583b0089b85b8bb5843da0709b9ddafd4e
6
+ metadata.gz: 52e34466cd3d104c64746aeecfe69e8c79b44fcb8e75bc3c2005b3a79027838422e480486ae44dc0b9cae2c40fbc1fcba68e8da0950f272e01695d5fe3f53783
7
+ data.tar.gz: 380679dc0b84274ee54ea25fa0860b93e041e98020b17947e9673a5c75a83022f1ee144afc746d31b27650d467fe9b77ac14801f63175ae531e49fac1abe71d2
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-05-14 10:12:34 UTC using RuboCop version 1.86.1.
3
+ # on 2026-05-27 07:40:36 UTC using RuboCop version 1.86.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,15 +11,38 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'lutaml-jsonschema.gemspec'
13
13
 
14
- # Offense count: 1
14
+ # Offense count: 2
15
+ # This cop supports safe autocorrection (--autocorrect).
16
+ # Configuration parameters: EnforcedStyle, IndentationWidth.
17
+ # SupportedStyles: with_first_element, with_fixed_indentation
18
+ Layout/ArrayAlignment:
19
+ Exclude:
20
+ - 'spec/lutaml/spa_builder_spec.rb'
21
+
22
+ # Offense count: 5
15
23
  # This cop supports safe autocorrection (--autocorrect).
16
24
  # Configuration parameters: EnforcedStyleAlignWith.
17
25
  # SupportedStylesAlignWith: either, start_of_block, start_of_line
18
26
  Layout/BlockAlignment:
19
27
  Exclude:
20
28
  - 'lib/lutaml/jsonschema/spa/spa_builder.rb'
29
+ - 'spec/lutaml/spa_builder_spec.rb'
30
+
31
+ # Offense count: 4
32
+ # This cop supports safe autocorrection (--autocorrect).
33
+ Layout/BlockEndNewline:
34
+ Exclude:
35
+ - 'spec/lutaml/spa_builder_spec.rb'
36
+
37
+ # Offense count: 8
38
+ # This cop supports safe autocorrection (--autocorrect).
39
+ # Configuration parameters: Width, EnforcedStyleAlignWith, AllowedPatterns.
40
+ # SupportedStylesAlignWith: start_of_line, relative_to_receiver
41
+ Layout/IndentationWidth:
42
+ Exclude:
43
+ - 'spec/lutaml/spa_builder_spec.rb'
21
44
 
22
- # Offense count: 28
45
+ # Offense count: 34
23
46
  # This cop supports safe autocorrection (--autocorrect).
24
47
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
25
48
  # URISchemes: http, https
@@ -62,7 +85,7 @@ Metrics/AbcSize:
62
85
  - 'lib/lutaml/jsonschema/schema_set.rb'
63
86
  - 'lib/lutaml/jsonschema/spa/spa_builder.rb'
64
87
 
65
- # Offense count: 3
88
+ # Offense count: 5
66
89
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
67
90
  # AllowedMethods: refine
68
91
  Metrics/BlockLength:
@@ -78,7 +101,7 @@ Metrics/CyclomaticComplexity:
78
101
  - 'lib/lutaml/jsonschema/reference_resolver.rb'
79
102
  - 'lib/lutaml/jsonschema/schema_set.rb'
80
103
 
81
- # Offense count: 17
104
+ # Offense count: 18
82
105
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
83
106
  Metrics/MethodLength:
84
107
  Max: 47
@@ -107,6 +130,17 @@ Naming/PredicateMethod:
107
130
  Exclude:
108
131
  - 'lib/lutaml/jsonschema/schema_set.rb'
109
132
 
133
+ # Offense count: 7
134
+ # This cop supports safe autocorrection (--autocorrect).
135
+ # Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods.
136
+ # SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces
137
+ # ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object
138
+ # FunctionalMethods: let, let!, subject, watch
139
+ # AllowedMethods: lambda, proc, it
140
+ Style/BlockDelimiters:
141
+ Exclude:
142
+ - 'spec/lutaml/spa_builder_spec.rb'
143
+
110
144
  # Offense count: 2
111
145
  # This cop supports unsafe autocorrection (--autocorrect-all).
112
146
  Style/IdenticalConditionalBranches:
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest'
5
+ import { createPinia, setActivePinia } from 'pinia'
6
+ import { useSchemaStore } from '../stores/schemaStore'
7
+ import {
8
+ primaryType,
9
+ displayType,
10
+ refLabel,
11
+ humanizeConstraints,
12
+ hasConstraints,
13
+ } from '../composables/useSchemaTypes'
14
+ import { resolveSchemaRef } from '../composables/useDefinitionResolver'
15
+ import { createField, buildDefaultJson } from '../composables/useBuilderField'
16
+ import type { SpaDocument, SpaProperty, SpaDefinition, SpaSchema } from '../types'
17
+
18
+ function prop(overrides: Partial<SpaProperty> = {}): SpaProperty {
19
+ return { name: 'test', ...overrides }
20
+ }
21
+
22
+ function definition(overrides: Partial<SpaDefinition> = {}): SpaDefinition {
23
+ return { name: 'Test', properties: [], required: [], ...overrides }
24
+ }
25
+
26
+ function schema(overrides: Partial<SpaSchema> = {}): SpaSchema {
27
+ return { name: 'TestSchema', properties: [], definitions: [], required: [], ...overrides }
28
+ }
29
+
30
+ /**
31
+ * Build a SpaDocument that mimics what the Ruby backend outputs
32
+ * for a complex schema like ISO 19115-4 mdj.json.
33
+ */
34
+ function buildComplexDoc(): SpaDocument {
35
+ const mdIdentifier: SpaDefinition = {
36
+ name: 'MD_Identifier',
37
+ title: 'Identifier',
38
+ type: 'object',
39
+ properties: [
40
+ prop({ name: 'code', type: 'string', required: true }),
41
+ prop({ name: 'codeSpace', type: 'string', format: 'uri' }),
42
+ ],
43
+ required: ['code'],
44
+ }
45
+
46
+ const topicCategory: SpaDefinition = {
47
+ name: 'MD_TopicCategoryCode',
48
+ title: 'Topic Category',
49
+ type: 'string',
50
+ enum: ['farming', 'biota', 'boundaries', 'climatology', 'economy', 'elevation'],
51
+ properties: [],
52
+ required: [],
53
+ }
54
+
55
+ const obligationCode: SpaDefinition = {
56
+ name: 'MD_ObligationCode',
57
+ type: 'string',
58
+ enum: ['mandatory', 'optional', 'conditional'],
59
+ properties: [],
60
+ required: [],
61
+ }
62
+
63
+ const ciTelephone: SpaDefinition = {
64
+ name: 'CI_Telephone',
65
+ type: 'object',
66
+ properties: [
67
+ prop({ name: 'number', type: 'string' }),
68
+ ],
69
+ required: [],
70
+ }
71
+
72
+ const durationType: SpaDefinition = {
73
+ name: 'DurationType',
74
+ type: 'string',
75
+ format: 'duration',
76
+ pattern: '^P.*$',
77
+ properties: [],
78
+ required: [],
79
+ }
80
+
81
+ const constraintUnion: SpaDefinition = {
82
+ name: 'Abstract_ConstraintUnion',
83
+ hasOneOf: true,
84
+ properties: [],
85
+ required: [],
86
+ }
87
+
88
+ const mdMetadata: SpaDefinition = {
89
+ name: 'MD_Metadata',
90
+ title: 'Metadata',
91
+ type: 'object',
92
+ description: 'Root metadata object',
93
+ properties: [
94
+ prop({ name: 'name', type: 'string', required: true }),
95
+ prop({
96
+ name: 'identifiers',
97
+ type: 'array',
98
+ itemsType: 'object',
99
+ itemsRef: '#/$defs/MD_Identifier',
100
+ }),
101
+ prop({
102
+ name: 'topicCategory',
103
+ ref: '#/$defs/MD_TopicCategoryCode',
104
+ type: 'string',
105
+ enum: ['farming', 'biota', 'boundaries', 'climatology', 'economy', 'elevation'],
106
+ }),
107
+ prop({
108
+ name: 'contacts',
109
+ type: 'array',
110
+ itemsType: 'object',
111
+ itemsRef: '#/$defs/CI_Contact',
112
+ minItems: 1,
113
+ }),
114
+ ],
115
+ required: ['name'],
116
+ }
117
+
118
+ const ciContact: SpaDefinition = {
119
+ name: 'CI_Contact',
120
+ type: 'object',
121
+ properties: [
122
+ prop({
123
+ name: 'phone',
124
+ type: 'array',
125
+ itemsType: 'object',
126
+ itemsRef: '#/$defs/CI_Telephone',
127
+ }),
128
+ ],
129
+ required: [],
130
+ }
131
+
132
+ return {
133
+ metadata: { title: 'ISO 19115-4 Metadata' },
134
+ schemas: [{
135
+ name: 'mdj',
136
+ title: 'ISO 19115-4',
137
+ type: 'object',
138
+ properties: [],
139
+ definitions: [
140
+ mdMetadata,
141
+ mdIdentifier,
142
+ topicCategory,
143
+ obligationCode,
144
+ ciContact,
145
+ ciTelephone,
146
+ durationType,
147
+ constraintUnion,
148
+ ],
149
+ required: [],
150
+ sourceJson: '{}',
151
+ }],
152
+ searchIndex: [],
153
+ }
154
+ }
155
+
156
+ describe('SPA data completeness for complex schemas', () => {
157
+ let store: ReturnType<typeof useSchemaStore>
158
+
159
+ beforeEach(() => {
160
+ setActivePinia(createPinia())
161
+ store = useSchemaStore()
162
+ })
163
+
164
+ describe('backend data integrity', () => {
165
+ const doc = buildComplexDoc()
166
+
167
+ it('array properties have itemsRef resolved from items.$ref', () => {
168
+ const mdMetadata = doc.schemas[0].definitions.find(d => d.name === 'MD_Metadata')!
169
+ const identifiers = mdMetadata.properties.find(p => p.name === 'identifiers')!
170
+ expect(identifiers.itemsRef).toBe('#/$defs/MD_Identifier')
171
+ expect(identifiers.itemsType).toBe('object')
172
+ })
173
+
174
+ it('array properties with itemsRef show correct display type', () => {
175
+ const mdMetadata = doc.schemas[0].definitions.find(d => d.name === 'MD_Metadata')!
176
+ const identifiers = mdMetadata.properties.find(p => p.name === 'identifiers')!
177
+ expect(displayType(identifiers)).toBe('array of MD_Identifier')
178
+ })
179
+
180
+ it('enum definitions carry their enum values', () => {
181
+ const topicCat = doc.schemas[0].definitions.find(d => d.name === 'MD_TopicCategoryCode')!
182
+ expect(topicCat.enum).toHaveLength(6)
183
+ expect(topicCat.enum).toContain('farming')
184
+ expect(topicCat.enum).toContain('elevation')
185
+ })
186
+
187
+ it('properties resolved from enum definitions carry enum values', () => {
188
+ const mdMetadata = doc.schemas[0].definitions.find(d => d.name === 'MD_Metadata')!
189
+ const topic = mdMetadata.properties.find(p => p.name === 'topicCategory')!
190
+ expect(topic.enum).toHaveLength(6)
191
+ expect(topic.ref).toBe('#/$defs/MD_TopicCategoryCode')
192
+ })
193
+
194
+ it('format and pattern on non-object definitions are preserved', () => {
195
+ const duration = doc.schemas[0].definitions.find(d => d.name === 'DurationType')!
196
+ expect(duration.format).toBe('duration')
197
+ expect(duration.pattern).toBe('^P.*$')
198
+ })
199
+
200
+ it('minItems on array properties is preserved', () => {
201
+ const mdMetadata = doc.schemas[0].definitions.find(d => d.name === 'MD_Metadata')!
202
+ const contacts = mdMetadata.properties.find(p => p.name === 'contacts')!
203
+ expect(contacts.minItems).toBe(1)
204
+ })
205
+
206
+ it('nested array itemsRef through definition chain', () => {
207
+ const ciContact = doc.schemas[0].definitions.find(d => d.name === 'CI_Contact')!
208
+ const phone = ciContact.properties.find(p => p.name === 'phone')!
209
+ expect(phone.itemsRef).toBe('#/$defs/CI_Telephone')
210
+ })
211
+
212
+ it('hasOneOf flag on union definitions', () => {
213
+ const constraint = doc.schemas[0].definitions.find(d => d.name === 'Abstract_ConstraintUnion')!
214
+ expect(constraint.hasOneOf).toBe(true)
215
+ })
216
+ })
217
+
218
+ describe('frontend rendering with store', () => {
219
+ it('store loads complex document and resolves definitions', () => {
220
+ window.SCHEMA_DATA = buildComplexDoc() as any
221
+ store.loadFromWindow()
222
+
223
+ const schemas = store.schemas
224
+ expect(schemas).toHaveLength(1)
225
+ expect(schemas[0].definitions).toHaveLength(8)
226
+ })
227
+
228
+ it('store normalizes $ref → ref on nested definition properties', () => {
229
+ const doc = buildComplexDoc()
230
+ // Simulate backend output where $ref is the JSON key
231
+ const topicProp = doc.schemas[0].definitions[0].properties.find(p => p.name === 'topicCategory')!
232
+ ;(topicProp as any).$ref = topicProp.ref
233
+ delete topicProp.ref
234
+
235
+ window.SCHEMA_DATA = doc as any
236
+ store.loadFromWindow()
237
+
238
+ const mdMetadata = store.schemas[0].definitions.find(d => d.name === 'MD_Metadata')!
239
+ const topic = mdMetadata.properties.find(p => p.name === 'topicCategory')!
240
+ expect(topic.ref).toBe('#/$defs/MD_TopicCategoryCode')
241
+ expect((topic as any).$ref).toBeUndefined()
242
+ })
243
+
244
+ it('definition resolver finds enum definitions', () => {
245
+ const s = schema({
246
+ definitions: [
247
+ definition({ name: 'MD_TopicCategoryCode', type: 'string', enum: ['farming', 'biota'] }),
248
+ ],
249
+ })
250
+ const resolved = resolveSchemaRef('#/$defs/MD_TopicCategoryCode', s)
251
+ expect(resolved).not.toBeNull()
252
+ expect(resolved!.enum).toEqual(['farming', 'biota'])
253
+ })
254
+ })
255
+
256
+ describe('type display for complex schema patterns', () => {
257
+ it('refLabel extracts definition name from $defs path', () => {
258
+ expect(refLabel('#/$defs/MD_Identifier')).toBe('MD_Identifier')
259
+ })
260
+
261
+ it('refLabel handles definitions path', () => {
262
+ expect(refLabel('#/definitions/address')).toBe('address')
263
+ })
264
+
265
+ it('displayType for array with itemsRef', () => {
266
+ expect(displayType(prop({ type: 'array', itemsRef: '#/$defs/MD_Identifier' }))).toBe('array of MD_Identifier')
267
+ })
268
+
269
+ it('prefers itemsRef label when itemsType is generic object', () => {
270
+ expect(displayType(prop({ type: 'array', itemsType: 'object', itemsRef: '#/$defs/Foo' }))).toBe('array of Foo')
271
+ })
272
+
273
+ it('uses itemsType when it is a specific primitive type', () => {
274
+ expect(displayType(prop({ type: 'array', itemsType: 'string', itemsRef: '#/$defs/Foo' }))).toBe('array of string')
275
+ })
276
+
277
+ it('displayType for array with itemsRef and constraints', () => {
278
+ const p = prop({ type: 'array', itemsRef: '#/$defs/MD_Identifier', minItems: 1, maxItems: 10 })
279
+ expect(displayType(p)).toBe('array of MD_Identifier [ 1 .. 10 ]')
280
+ })
281
+
282
+ it('displayType for nullable array with itemsRef', () => {
283
+ expect(displayType(prop({ type: 'array,null', itemsRef: '#/$defs/Item' }))).toBe('array of Item | null')
284
+ })
285
+ })
286
+
287
+ describe('builder field creation for complex properties', () => {
288
+ it('creates field for array property with itemsRef', () => {
289
+ const p = prop({ name: 'ids', type: 'array', itemsType: 'object', itemsRef: '#/$defs/MD_Identifier' })
290
+ const s = schema({
291
+ definitions: [definition({ name: 'MD_Identifier', type: 'object', properties: [prop({ name: 'code', type: 'string' })], required: [] })],
292
+ })
293
+ const field = createField(p, [], s)
294
+ expect(field.arrayItems).toHaveLength(1)
295
+ expect(field.resolvedDef).toBeNull()
296
+ })
297
+
298
+ it('creates field for property resolved from enum definition', () => {
299
+ const p = prop({
300
+ name: 'topic',
301
+ ref: '#/$defs/MD_TopicCategoryCode',
302
+ type: 'string',
303
+ enum: ['farming', 'biota', 'boundaries'],
304
+ })
305
+ const s = schema({
306
+ definitions: [definition({ name: 'MD_TopicCategoryCode', type: 'string', enum: ['farming', 'biota', 'boundaries'], properties: [], required: [] })],
307
+ })
308
+ const field = createField(p, [], s)
309
+ // Enum property should have first enum as initial value
310
+ expect(field.rawValue).toBe('farming')
311
+ })
312
+
313
+ it('buildDefaultJson handles array items with itemsRef', () => {
314
+ const props = [
315
+ prop({ name: 'tags', type: 'array', itemsType: 'string' }),
316
+ ]
317
+ const json = buildDefaultJson(props)
318
+ expect(json.tags).toEqual([''])
319
+ })
320
+ })
321
+
322
+ describe('constraint chips for complex definitions', () => {
323
+ it('enum definition has constraints detected via property resolver', () => {
324
+ const p = prop({
325
+ name: 'topic',
326
+ type: 'string',
327
+ enum: ['farming', 'biota'],
328
+ ref: '#/$defs/MD_TopicCategoryCode',
329
+ })
330
+ expect(hasConstraints(p)).toBe(true)
331
+ })
332
+
333
+ it('array with itemsRef and minItems has constraints', () => {
334
+ const p = prop({ name: 'items', type: 'array', itemsRef: '#/$defs/X', minItems: 1 })
335
+ expect(hasConstraints(p)).toBe(true)
336
+ })
337
+ })
338
+ })
@@ -17,6 +17,7 @@ import {
17
17
  numberRange,
18
18
  stringRange,
19
19
  itemsRange,
20
+ refLabel,
20
21
  } from '../composables/useSchemaTypes'
21
22
  import type { SpaProperty } from '../types'
22
23
 
@@ -86,6 +87,28 @@ describe('displayType', () => {
86
87
  it('shows array without itemsType', () => {
87
88
  expect(displayType(prop({ type: 'array' }))).toBe('array')
88
89
  })
90
+
91
+ it('shows array with itemsRef when itemsType is missing', () => {
92
+ expect(displayType(prop({ type: 'array', itemsRef: '#/$defs/MD_Identifier' }))).toBe('array of MD_Identifier')
93
+ })
94
+
95
+ it('prefers itemsType over itemsRef when both present', () => {
96
+ expect(displayType(prop({ type: 'array', itemsType: 'string', itemsRef: '#/$defs/SomeDef' }))).toBe('array of string')
97
+ })
98
+ })
99
+
100
+ describe('refLabel', () => {
101
+ it('extracts last segment from $defs ref', () => {
102
+ expect(refLabel('#/$defs/MD_Identifier')).toBe('MD_Identifier')
103
+ })
104
+
105
+ it('extracts last segment from definitions ref', () => {
106
+ expect(refLabel('#/definitions/address')).toBe('address')
107
+ })
108
+
109
+ it('returns last segment for any path', () => {
110
+ expect(refLabel('#/a/b/c')).toBe('c')
111
+ })
89
112
  })
90
113
 
91
114
  describe('formatInputType', () => {
@@ -140,6 +140,30 @@
140
140
  <span class="meta-label">Additional</span>
141
141
  <span class="badge badge-locked-detail">Denied</span>
142
142
  </div>
143
+ <div v-if="definitionItem?.format" class="meta-row">
144
+ <span class="meta-label">Format</span>
145
+ <span class="badge badge-format">{{ definitionItem.format }}</span>
146
+ </div>
147
+ <div v-if="definitionItem?.pattern" class="meta-row">
148
+ <span class="meta-label">Pattern</span>
149
+ <span class="font-mono constraint-pattern">{{ definitionItem.pattern }}</span>
150
+ </div>
151
+ <div v-if="definitionItem?.default" class="meta-row">
152
+ <span class="meta-label">Default</span>
153
+ <span class="font-mono">{{ definitionItem.default }}</span>
154
+ </div>
155
+ <div v-if="definitionItem?.minLength != null || definitionItem?.maxLength != null" class="meta-row">
156
+ <span class="meta-label">Length</span>
157
+ <span class="text-secondary">{{ defStringRange }}</span>
158
+ </div>
159
+ <div v-if="definitionItem?.minimum != null || definitionItem?.maximum != null || definitionItem?.exclusiveMinimum != null || definitionItem?.exclusiveMaximum != null" class="meta-row">
160
+ <span class="meta-label">Range</span>
161
+ <span class="text-secondary">{{ defNumberRange }}</span>
162
+ </div>
163
+ <div v-if="definitionItem?.multipleOf != null" class="meta-row">
164
+ <span class="meta-label">Multiple Of</span>
165
+ <span class="text-secondary">{{ definitionItem.multipleOf }}</span>
166
+ </div>
143
167
  </div>
144
168
  </div>
145
169
 
@@ -173,9 +197,12 @@
173
197
  </div>
174
198
  </td>
175
199
  </tr>
176
- <tr v-if="propertyItem.itemsType">
200
+ <tr v-if="propertyItem.itemsType || propertyItem.itemsRef">
177
201
  <td class="constraint-key">Items Type</td>
178
- <td class="constraint-value">{{ propertyItem.itemsType }}</td>
202
+ <td class="constraint-value">
203
+ {{ propertyItem.itemsType || 'object' }}
204
+ <span v-if="propertyItem.itemsRef" class="prop-ref-link" role="button" tabindex="0" @click.stop="navigateToRef(propertyItem.itemsRef)" @keydown.enter="navigateToRef(propertyItem.itemsRef)"> → {{ refName(propertyItem.itemsRef) }}</span>
205
+ </td>
179
206
  </tr>
180
207
  <tr v-if="propertyItem.uniqueItems">
181
208
  <td class="constraint-key">Unique Items</td>
@@ -204,6 +231,52 @@
204
231
  </tbody>
205
232
  </table>
206
233
  </div>
234
+
235
+ <!-- Constraints for definition (enum, pattern, etc.) -->
236
+ <div v-if="definitionItem && hasDefConstraints" class="detail-section">
237
+ <h3 class="detail-heading">Constraints</h3>
238
+ <table class="table constraint-table">
239
+ <tbody>
240
+ <tr v-if="definitionItem.enum?.length">
241
+ <td class="constraint-key">Enum</td>
242
+ <td>
243
+ <div class="enum-values-list">
244
+ <span v-for="e in visibleEnumValues(definitionItem.name, definitionItem.enum)" :key="e" class="enum-value-chip">{{ e }}</span>
245
+ <button v-if="definitionItem.enum.length > 8 && !detailEnumExpanded.has(definitionItem.name)" class="enum-more-btn" @click="toggleDetailEnum(definitionItem.name)">+{{ definitionItem.enum.length - 8 }} more</button>
246
+ </div>
247
+ </td>
248
+ </tr>
249
+ <tr v-if="definitionItem.const">
250
+ <td class="constraint-key">Const</td>
251
+ <td class="constraint-value font-mono">{{ definitionItem.const }}</td>
252
+ </tr>
253
+ <tr v-if="definitionItem.pattern">
254
+ <td class="constraint-key">Pattern</td>
255
+ <td class="constraint-value font-mono constraint-pattern">{{ definitionItem.pattern }}</td>
256
+ </tr>
257
+ <tr v-if="definitionItem.minimum != null || definitionItem.maximum != null || definitionItem.exclusiveMinimum != null || definitionItem.exclusiveMaximum != null">
258
+ <td class="constraint-key">Range</td>
259
+ <td class="constraint-value">{{ defNumberRange }}</td>
260
+ </tr>
261
+ <tr v-if="definitionItem.minLength != null || definitionItem.maxLength != null">
262
+ <td class="constraint-key">Length</td>
263
+ <td class="constraint-value">{{ defStringRange }}</td>
264
+ </tr>
265
+ <tr v-if="definitionItem.multipleOf != null">
266
+ <td class="constraint-key">Multiple Of</td>
267
+ <td class="constraint-value">{{ definitionItem.multipleOf }}</td>
268
+ </tr>
269
+ <tr v-if="definitionItem.contentMediaType">
270
+ <td class="constraint-key">Content Type</td>
271
+ <td class="constraint-value">{{ definitionItem.contentMediaType }}</td>
272
+ </tr>
273
+ <tr v-if="definitionItem.contentEncoding">
274
+ <td class="constraint-key">Content Encoding</td>
275
+ <td class="constraint-value">{{ definitionItem.contentEncoding }}</td>
276
+ </tr>
277
+ </tbody>
278
+ </table>
279
+ </div>
207
280
  </template>
208
281
 
209
282
  <!-- Properties/Definition tab -->
@@ -231,6 +304,7 @@
231
304
  <span class="prop-type-badge" :class="propTypeClass(prop.type)">{{ prop.type || 'any' }}</span>
232
305
  <span v-if="prop.format" class="prop-format">&lt;{{ prop.format }}&gt;</span>
233
306
  <span v-if="prop.itemsType" class="prop-format">[{{ prop.itemsType }}]</span>
307
+ <span v-if="prop.itemsRef" class="prop-format">[<span class="prop-ref-link" role="button" tabindex="0" @click.stop="navigateToRef(prop.itemsRef)" @keydown.enter="navigateToRef(prop.itemsRef)">{{ refName(prop.itemsRef) }}</span>]</span>
234
308
  </template>
235
309
  <span v-if="prop.default != null" class="prop-default">default: {{ prop.default }}</span>
236
310
  <span v-if="prop.enum?.length" class="prop-enum">{{ prop.enum.length }} values</span>
@@ -385,7 +459,7 @@ const hasConstraints = computed(() => {
385
459
  if (!p) return false
386
460
  return p.minimum != null || p.maximum != null ||
387
461
  p.minLength != null || p.maxLength != null ||
388
- p.pattern || p.enum?.length || p.itemsType ||
462
+ p.pattern || p.enum?.length || p.itemsType || p.itemsRef ||
389
463
  p.exclusiveMinimum != null || p.exclusiveMaximum != null ||
390
464
  p.minItems != null || p.maxItems != null || p.uniqueItems ||
391
465
  p.multipleOf != null || p.const != null ||
@@ -393,6 +467,16 @@ const hasConstraints = computed(() => {
393
467
  p.additionalProperties === false
394
468
  })
395
469
 
470
+ const hasDefConstraints = computed(() => {
471
+ const d = definitionItem.value
472
+ if (!d) return false
473
+ return d.enum?.length > 0 || !!d.const || !!d.pattern ||
474
+ d.minimum != null || d.maximum != null ||
475
+ d.exclusiveMinimum != null || d.exclusiveMaximum != null ||
476
+ d.minLength != null || d.maxLength != null ||
477
+ d.multipleOf != null || !!d.contentMediaType || !!d.contentEncoding
478
+ })
479
+
396
480
  const numberRangeLabel = computed(() => {
397
481
  const p = propertyItem.value
398
482
  return p ? numberRange(p) ?? '' : ''
@@ -408,6 +492,18 @@ const itemsRangeLabel = computed(() => {
408
492
  return p ? itemsRange(p) ?? '' : ''
409
493
  })
410
494
 
495
+ const defNumberRange = computed(() => {
496
+ const d = definitionItem.value
497
+ if (!d) return ''
498
+ return numberRange(d as any) ?? ''
499
+ })
500
+
501
+ const defStringRange = computed(() => {
502
+ const d = definitionItem.value
503
+ if (!d) return ''
504
+ return stringRange(d as any) ?? ''
505
+ })
506
+
411
507
  type TabId = 'overview' | 'properties' | 'examples'
412
508
 
413
509
  const examples = computed(() => {
@@ -15,6 +15,12 @@ export function isNullableType(type?: string): boolean {
15
15
  return (type || '').split(',').map(s => s.trim()).includes('null')
16
16
  }
17
17
 
18
+ /** Extract the definition name from a $ref string (e.g. "#/$defs/MD_Identifier" → "MD_Identifier"). */
19
+ export function refLabel(ref: string): string {
20
+ const parts = ref.split('/')
21
+ return parts[parts.length - 1]
22
+ }
23
+
18
24
  /** Whether the type string is a composition indicator from the backend. */
19
25
  export function isCompositionType(type?: string): boolean {
20
26
  const t = type || ''
@@ -31,7 +37,10 @@ export function displayType(prop: SpaProperty, resolvedTitle?: string): string {
31
37
  const suffix = isNullableType(prop.type) ? ' | null' : ''
32
38
  if (isCompositionType(t)) return t + suffix
33
39
  if (t === 'array') {
34
- let label = prop.itemsType ? `array of ${prop.itemsType}` : 'array'
40
+ const itemType = (prop.itemsRef && (!prop.itemsType || prop.itemsType === 'object'))
41
+ ? refLabel(prop.itemsRef)
42
+ : prop.itemsType
43
+ let label = itemType ? `array of ${itemType}` : 'array'
35
44
  if (prop.minItems != null && prop.maxItems != null) label += ` [ ${prop.minItems} .. ${prop.maxItems} ]`
36
45
  else if (prop.minItems != null) label += ` >= ${prop.minItems}`
37
46
  else if (prop.maxItems != null) label += ` <= ${prop.maxItems}`
@@ -144,7 +153,7 @@ export function isObjectProperty(prop: SpaProperty): boolean {
144
153
  */
145
154
  export function hasConstraints(prop: SpaProperty): boolean {
146
155
  return !!(
147
- (prop.enum?.length && isObjectProperty(prop)) ||
156
+ prop.enum?.length ||
148
157
  prop.pattern ||
149
158
  prop.minimum != null ||
150
159
  prop.maximum != null ||
@@ -160,7 +169,8 @@ export function hasConstraints(prop: SpaProperty): boolean {
160
169
  prop.exclusiveMinimum != null ||
161
170
  prop.exclusiveMaximum != null ||
162
171
  prop.contentMediaType ||
163
- prop.contentEncoding
172
+ prop.contentEncoding ||
173
+ prop.itemsRef
164
174
  )
165
175
  }
166
176
 
@@ -25,6 +25,7 @@ export interface SpaProperty {
25
25
  minimum?: number
26
26
  maximum?: number
27
27
  itemsType?: string
28
+ itemsRef?: string
28
29
  deprecated?: boolean
29
30
  readOnly?: boolean
30
31
  writeOnly?: boolean
@@ -47,6 +48,20 @@ export interface SpaDefinition {
47
48
  title?: string
48
49
  description?: string
49
50
  type?: string
51
+ format?: string
52
+ enum?: string[]
53
+ const?: string
54
+ pattern?: string
55
+ default?: string
56
+ minLength?: number
57
+ maxLength?: number
58
+ minimum?: number
59
+ maximum?: number
60
+ exclusiveMinimum?: number
61
+ exclusiveMaximum?: number
62
+ multipleOf?: number
63
+ contentMediaType?: string
64
+ contentEncoding?: string
50
65
  properties: SpaProperty[]
51
66
  required: string[]
52
67
  examples?: string[]
@@ -163,6 +163,20 @@ module Lutaml
163
163
  title: s.title,
164
164
  description: s.description,
165
165
  type: s.type,
166
+ format: s.format,
167
+ enum: s.enum,
168
+ const_value: s.const,
169
+ pattern: s.pattern,
170
+ default: s.default,
171
+ min_length: s.min_length,
172
+ max_length: s.max_length,
173
+ minimum: s.minimum,
174
+ maximum: s.maximum,
175
+ exclusive_minimum: s.exclusive_minimum,
176
+ exclusive_maximum: s.exclusive_maximum,
177
+ multiple_of: s.multiple_of,
178
+ content_type: s.content_type,
179
+ content_encoding: s.content_encoding,
166
180
  properties: properties,
167
181
  required: all_required,
168
182
  examples: s.examples,
@@ -195,6 +209,7 @@ module Lutaml
195
209
  end
196
210
 
197
211
  prop_ref = resolve_prop_ref(entry.schema)
212
+ items_info = resolve_items_info(resolved, root_schema)
198
213
 
199
214
  SpaProperty.new(
200
215
  name: entry.name,
@@ -211,7 +226,8 @@ module Lutaml
211
226
  max_length: resolved.max_length,
212
227
  minimum: resolved.minimum,
213
228
  maximum: resolved.maximum,
214
- items_type: resolved.items&.type,
229
+ items_type: items_info[:type],
230
+ items_ref: items_info[:ref],
215
231
  deprecated: resolved.deprecated,
216
232
  read_only: resolved.read_only,
217
233
  write_only: resolved.write_only,
@@ -230,6 +246,23 @@ module Lutaml
230
246
  )
231
247
  end
232
248
 
249
+ def resolve_items_info(resolved, root_schema)
250
+ items = resolved.items
251
+ return { type: nil, ref: nil } unless items
252
+
253
+ if items.dollar_ref
254
+ ref = items.dollar_ref
255
+ resolved_items = @schema_set.resolve_ref(ref, root_schema)
256
+ if resolved_items
257
+ { type: resolved_items.type || resolved_items.title, ref: ref }
258
+ else
259
+ { type: nil, ref: ref }
260
+ end
261
+ else
262
+ { type: items.type, ref: nil }
263
+ end
264
+ end
265
+
233
266
  def resolve_prop_ref(schema)
234
267
  return schema.dollar_ref if schema.dollar_ref
235
268
 
@@ -8,6 +8,20 @@ module Lutaml
8
8
  attribute :title, :string
9
9
  attribute :description, :string
10
10
  attribute :type, :string
11
+ attribute :format, :string
12
+ attribute :enum, :string, collection: true
13
+ attribute :const_value, :string
14
+ attribute :pattern, :string
15
+ attribute :default, :string
16
+ attribute :min_length, :integer
17
+ attribute :max_length, :integer
18
+ attribute :minimum, :float
19
+ attribute :maximum, :float
20
+ attribute :exclusive_minimum, :float
21
+ attribute :exclusive_maximum, :float
22
+ attribute :multiple_of, :float
23
+ attribute :content_type, :string
24
+ attribute :content_encoding, :string
11
25
  attribute :properties, SpaProperty, collection: true,
12
26
  initialize_empty: true
13
27
  attribute :required, :string, collection: true
@@ -24,6 +38,20 @@ module Lutaml
24
38
  map "title", to: :title
25
39
  map "description", to: :description
26
40
  map "type", to: :type
41
+ map "format", to: :format
42
+ map "enum", to: :enum
43
+ map "const", to: :const_value
44
+ map "pattern", to: :pattern
45
+ map "default", to: :default
46
+ map "minLength", to: :min_length
47
+ map "maxLength", to: :max_length
48
+ map "minimum", to: :minimum
49
+ map "maximum", to: :maximum
50
+ map "exclusiveMinimum", to: :exclusive_minimum
51
+ map "exclusiveMaximum", to: :exclusive_maximum
52
+ map "multipleOf", to: :multiple_of
53
+ map "contentMediaType", to: :content_type
54
+ map "contentEncoding", to: :content_encoding
27
55
  map "properties", to: :properties
28
56
  map "required", to: :required
29
57
  map "examples", to: :examples
@@ -19,6 +19,7 @@ module Lutaml
19
19
  attribute :minimum, :float
20
20
  attribute :maximum, :float
21
21
  attribute :items_type, :string
22
+ attribute :items_ref, :string
22
23
  attribute :deprecated, :boolean
23
24
  attribute :read_only, :boolean
24
25
  attribute :write_only, :boolean
@@ -51,6 +52,7 @@ module Lutaml
51
52
  map "minimum", to: :minimum
52
53
  map "maximum", to: :maximum
53
54
  map "itemsType", to: :items_type
55
+ map "itemsRef", to: :items_ref
54
56
  map "deprecated", to: :deprecated
55
57
  map "readOnly", to: :read_only
56
58
  map "writeOnly", to: :write_only
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Jsonschema
5
- VERSION = "0.1.16"
5
+ VERSION = "0.1.17"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-jsonschema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.16
4
+ version: 0.1.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-14 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: json
@@ -95,6 +95,7 @@ files:
95
95
  - frontend/public/lutaml-logo-full-light.svg
96
96
  - frontend/public/lutaml-logo-light.svg
97
97
  - frontend/src/App.vue
98
+ - frontend/src/__tests__/spaDataCompleteness.test.ts
98
99
  - frontend/src/__tests__/useBuilderField.test.ts
99
100
  - frontend/src/__tests__/useClipboard.test.ts
100
101
  - frontend/src/__tests__/useDefinitionResolver.test.ts