lutaml-jsonschema 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/README.md +39 -0
  5. data/Rakefile +26 -0
  6. data/exe/lutaml-jsonschema +6 -0
  7. data/frontend/index.html +60 -0
  8. data/frontend/package-lock.json +2715 -0
  9. data/frontend/package.json +27 -0
  10. data/frontend/public/lutaml-logo-dark.svg +1 -0
  11. data/frontend/public/lutaml-logo-full-dark.svg +1 -0
  12. data/frontend/public/lutaml-logo-full-light.svg +1 -0
  13. data/frontend/public/lutaml-logo-light.svg +1 -0
  14. data/frontend/src/App.vue +80 -0
  15. data/frontend/src/__tests__/useBuilderField.test.ts +137 -0
  16. data/frontend/src/__tests__/useDefinitionResolver.test.ts +46 -0
  17. data/frontend/src/__tests__/useSchemaTypes.test.ts +219 -0
  18. data/frontend/src/app.ts +10 -0
  19. data/frontend/src/components/AppHeader.vue +152 -0
  20. data/frontend/src/components/AppSidebar.vue +427 -0
  21. data/frontend/src/components/DetailPanel.vue +403 -0
  22. data/frontend/src/components/SchemaBuilder.vue +543 -0
  23. data/frontend/src/components/SchemaStructure.vue +168 -0
  24. data/frontend/src/components/SearchModal.vue +275 -0
  25. data/frontend/src/composables/useBuilderField.ts +92 -0
  26. data/frontend/src/composables/useDefinitionResolver.ts +17 -0
  27. data/frontend/src/composables/useSchemaTypes.ts +152 -0
  28. data/frontend/src/composables/useSearch.ts +104 -0
  29. data/frontend/src/router.ts +14 -0
  30. data/frontend/src/stores/schemaStore.ts +118 -0
  31. data/frontend/src/stores/uiStore.ts +78 -0
  32. data/frontend/src/style.css +194 -0
  33. data/frontend/src/types.ts +70 -0
  34. data/frontend/src/views/HomeView.vue +396 -0
  35. data/frontend/tsconfig.json +20 -0
  36. data/frontend/vite.config.ts +28 -0
  37. data/lib/lutaml/jsonschema/base.rb +11 -0
  38. data/lib/lutaml/jsonschema/cli.rb +102 -0
  39. data/lib/lutaml/jsonschema/combiner.rb +54 -0
  40. data/lib/lutaml/jsonschema/configuration.rb +47 -0
  41. data/lib/lutaml/jsonschema/link.rb +25 -0
  42. data/lib/lutaml/jsonschema/property_entry.rb +15 -0
  43. data/lib/lutaml/jsonschema/reference_resolver.rb +74 -0
  44. data/lib/lutaml/jsonschema/schema.rb +205 -0
  45. data/lib/lutaml/jsonschema/schema_set.rb +217 -0
  46. data/lib/lutaml/jsonschema/spa/generator.rb +22 -0
  47. data/lib/lutaml/jsonschema/spa/metadata.rb +23 -0
  48. data/lib/lutaml/jsonschema/spa/output_strategy.rb +17 -0
  49. data/lib/lutaml/jsonschema/spa/spa_builder.rb +178 -0
  50. data/lib/lutaml/jsonschema/spa/spa_definition.rb +27 -0
  51. data/lib/lutaml/jsonschema/spa/spa_document.rb +23 -0
  52. data/lib/lutaml/jsonschema/spa/spa_property.rb +47 -0
  53. data/lib/lutaml/jsonschema/spa/spa_schema.rb +29 -0
  54. data/lib/lutaml/jsonschema/spa/spa_search_entry.rb +21 -0
  55. data/lib/lutaml/jsonschema/spa/vue_inlined_strategy.rb +53 -0
  56. data/lib/lutaml/jsonschema/version.rb +7 -0
  57. data/lib/lutaml/jsonschema.rb +29 -0
  58. data/sig/lutaml/jsonschema.rbs +6 -0
  59. metadata +163 -0
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ class ReferenceResolver
6
+ def initialize(schemas = {})
7
+ @schemas = schemas
8
+ end
9
+
10
+ def resolve(ref, root_schema = nil)
11
+ return nil unless ref
12
+
13
+ if ref.start_with?("#/")
14
+ resolve_local(ref, root_schema)
15
+ else
16
+ resolve_remote(ref)
17
+ end
18
+ end
19
+
20
+ def resolve_local(ref, schema)
21
+ parts = ref.delete_prefix("#/").split("/")
22
+ current = schema
23
+
24
+ parts.each do |part|
25
+ case current
26
+ when Schema
27
+ current = navigate_schema(current, part)
28
+ when Array
29
+ entry = current.find { |e| e.is_a?(PropertyEntry) && e.name == part }
30
+ current = entry&.schema
31
+ else
32
+ return nil
33
+ end
34
+ return nil unless current
35
+ end
36
+
37
+ current.is_a?(Schema) && current.dollar_ref ? resolve(current.dollar_ref, schema) : current
38
+ end
39
+
40
+ def resolve_remote(ref)
41
+ return nil unless @schemas
42
+
43
+ if ref.include?("#")
44
+ file_path, fragment = ref.split("#", 2)
45
+ schema = @schemas[file_path]
46
+ schema ? resolve_local("##{fragment}", schema) : nil
47
+ else
48
+ @schemas[ref]
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def navigate_schema(schema, part)
55
+ case part
56
+ when "definitions", "$defs"
57
+ schema.definition_entries
58
+ when "properties"
59
+ schema.property_entries
60
+ when "patternProperties"
61
+ schema.pattern_property_entries
62
+ when "items"
63
+ schema.items
64
+ when "allOf" then schema.all_of
65
+ when "anyOf" then schema.any_of
66
+ when "oneOf" then schema.one_of
67
+ else
68
+ entries = schema.definition_entries || []
69
+ entries.find { |e| e.name == part }&.schema
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ class Schema < Base
6
+ # Document-level keywords
7
+ attribute :dollar_schema, :string
8
+ attribute :dollar_id, :string
9
+ attribute :dollar_ref, :string
10
+ attribute :dollar_comment, :string
11
+ attribute :dollar_anchor, :string
12
+
13
+ # Metadata
14
+ attribute :title, :string
15
+ attribute :description, :string
16
+ attribute :default, :string
17
+ attribute :examples, :string, collection: true
18
+ attribute :deprecated, :boolean
19
+ attribute :read_only, :boolean
20
+ attribute :write_only, :boolean
21
+
22
+ # Type keywords
23
+ attribute :type, :string
24
+ attribute :format, :string
25
+
26
+ # Object keywords
27
+ attribute :property_entries, PropertyEntry, collection: true, initialize_empty: true
28
+ attribute :pattern_property_entries, PropertyEntry, collection: true, initialize_empty: true
29
+ attribute :required, :string, collection: true, initialize_empty: true
30
+ attribute :additional_properties, :boolean
31
+ attribute :additional_properties_schema, Schema
32
+ attribute :min_properties, :integer
33
+ attribute :max_properties, :integer
34
+
35
+ # Array keywords
36
+ attribute :items, Schema
37
+ attribute :min_items, :integer
38
+ attribute :max_items, :integer
39
+ attribute :unique_items, :boolean
40
+ attribute :contains, Schema
41
+
42
+ # Numeric keywords
43
+ attribute :minimum, :float
44
+ attribute :maximum, :float
45
+ attribute :exclusive_minimum, :float
46
+ attribute :exclusive_maximum, :float
47
+ attribute :multiple_of, :float
48
+
49
+ # String keywords
50
+ attribute :min_length, :integer
51
+ attribute :max_length, :integer
52
+ attribute :pattern, :string
53
+ attribute :content_type, :string
54
+ attribute :content_encoding, :string
55
+
56
+ # Enum / const
57
+ attribute :enum, :string, collection: true
58
+ attribute :const, :string
59
+
60
+ # Composition (recursive)
61
+ attribute :all_of, Schema, collection: true, initialize_empty: true
62
+ attribute :any_of, Schema, collection: true, initialize_empty: true
63
+ attribute :one_of, Schema, collection: true, initialize_empty: true
64
+ attribute :not_schema, Schema
65
+
66
+ # Conditional
67
+ attribute :if_schema, Schema
68
+ attribute :then_schema, Schema
69
+ attribute :else_schema, Schema
70
+
71
+ # Definitions ($defs / definitions)
72
+ attribute :definition_entries, PropertyEntry, collection: true, initialize_empty: true
73
+
74
+ # Hyper-schema links
75
+ attribute :links, Link, collection: true, initialize_empty: true
76
+
77
+ json do
78
+ map "$schema", to: :dollar_schema
79
+ map ["$id", "id"], to: :dollar_id
80
+ map "$ref", to: :dollar_ref
81
+ map "$comment", to: :dollar_comment
82
+ map "$anchor", to: :dollar_anchor
83
+ map "title", to: :title
84
+ map "description", to: :description
85
+ map "default", to: :default
86
+ map ["examples", "example"], to: :examples
87
+ map "deprecated", to: :deprecated
88
+ map "readOnly", to: :read_only
89
+ map "writeOnly", to: :write_only
90
+ map "type", to: :type,
91
+ with: { from: :parse_type, to: :serialize_type }
92
+ map "format", to: :format
93
+ map "properties", to: :property_entries,
94
+ child_mappings: { name: :key, schema: :value }
95
+ map "patternProperties", to: :pattern_property_entries,
96
+ child_mappings: { name: :key, schema: :value }
97
+ map "required", to: :required
98
+ map "additionalProperties", to: :additional_properties,
99
+ with: { from: :parse_additional_properties,
100
+ to: :serialize_additional_properties }
101
+ map "minProperties", to: :min_properties
102
+ map "maxProperties", to: :max_properties
103
+ map "items", to: :items
104
+ map "minItems", to: :min_items
105
+ map "maxItems", to: :max_items
106
+ map "uniqueItems", to: :unique_items
107
+ map "contains", to: :contains
108
+ map "minimum", to: :minimum
109
+ map "maximum", to: :maximum
110
+ map "exclusiveMinimum", to: :exclusive_minimum
111
+ map "exclusiveMaximum", to: :exclusive_maximum
112
+ map "multipleOf", to: :multiple_of
113
+ map "minLength", to: :min_length
114
+ map "maxLength", to: :max_length
115
+ map "pattern", to: :pattern
116
+ map "contentMediaType", to: :content_type
117
+ map "contentEncoding", to: :content_encoding
118
+ map "enum", to: :enum
119
+ map "const", to: :const
120
+ map "allOf", to: :all_of
121
+ map "anyOf", to: :any_of
122
+ map "oneOf", to: :one_of
123
+ map "not", to: :not_schema
124
+ map "if", to: :if_schema
125
+ map "then", to: :then_schema
126
+ map "else", to: :else_schema
127
+ map "$defs", to: :definition_entries,
128
+ child_mappings: { name: :key, schema: :value }
129
+ map "definitions", to: :definition_entries,
130
+ with: { from: :parse_legacy_definitions, to: :noop_serializer }
131
+ map "links", to: :links
132
+ end
133
+
134
+ def parse_type(instance, value)
135
+ instance.type = value.is_a?(Array) ? value.join(",") : value
136
+ end
137
+
138
+ def serialize_type(instance, hash)
139
+ t = instance.type
140
+ return unless t
141
+
142
+ parts = t.split(",")
143
+ hash["type"] = parts.length == 1 ? parts.first : parts
144
+ end
145
+
146
+ def parse_additional_properties(instance, value)
147
+ case value
148
+ when true, false
149
+ instance.additional_properties = value
150
+ when Hash
151
+ instance.additional_properties_schema = Schema.from_json(value.to_json)
152
+ end
153
+ end
154
+
155
+ def serialize_additional_properties(instance, hash)
156
+ if instance.additional_properties_schema
157
+ hash["additionalProperties"] = JSON.parse(instance.additional_properties_schema.to_json)
158
+ elsif !instance.additional_properties.nil?
159
+ hash["additionalProperties"] = instance.additional_properties
160
+ end
161
+ end
162
+
163
+ def object?
164
+ types.include?("object")
165
+ end
166
+
167
+ def array?
168
+ types.include?("array")
169
+ end
170
+
171
+ def types
172
+ return [] unless type
173
+
174
+ type.split(",")
175
+ end
176
+
177
+ def ref?
178
+ !!dollar_ref
179
+ end
180
+
181
+ def all_property_entries
182
+ result = property_entries.dup
183
+ all_of.each { |s| result.concat(s.all_property_entries) }
184
+ result
185
+ end
186
+
187
+ def noop_serializer(_instance, _hash); end
188
+
189
+ def parse_legacy_definitions(instance, value)
190
+ return unless value.is_a?(Hash)
191
+
192
+ value.each do |name, schema_hash|
193
+ next if instance.definition_entries.any? { |e| e.name == name }
194
+
195
+ instance.definition_entries.push(
196
+ PropertyEntry.new(
197
+ name: name,
198
+ schema: Schema.from_json(schema_hash.to_json)
199
+ )
200
+ )
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ class SchemaSet
6
+ attr_reader :schemas, :base_dir
7
+
8
+ def initialize(base_dir: nil)
9
+ @schemas = {}
10
+ @base_dir = base_dir
11
+ @resolver = ReferenceResolver.new(@schemas)
12
+ end
13
+
14
+ def self.load_from_files(*paths, base_dir: nil)
15
+ set = new(base_dir: base_dir || infer_base_dir(paths))
16
+ paths.each do |path|
17
+ data = File.read(path)
18
+ schema = Schema.from_json(data)
19
+ name = File.basename(path, ".*")
20
+ set.add(name, schema, path)
21
+ end
22
+ set
23
+ end
24
+
25
+ def self.load_from_directory(dir)
26
+ paths = Dir.glob(File.join(dir, "*.json"))
27
+ load_from_files(*paths, base_dir: dir)
28
+ end
29
+
30
+ def add(name, schema, file_path = nil)
31
+ @schemas[name] = schema
32
+ return unless file_path
33
+
34
+ @file_paths ||= {}
35
+ @file_paths[File.basename(file_path)] = file_path
36
+ end
37
+
38
+ def resolve_ref(ref_string, context_schema = nil)
39
+ return nil unless ref_string
40
+
41
+ if ref_string.start_with?("./")
42
+ resolve_file_ref(ref_string, context_schema)
43
+ elsif ref_string.start_with?("http://", "https://")
44
+ resolve_remote_ref(ref_string)
45
+ elsif ref_string.start_with?("#/")
46
+ @resolver.resolve(ref_string, context_schema)
47
+ elsif ref_string.start_with?("#") && !ref_string.start_with?("#/")
48
+ resolve_anchor_ref(ref_string.delete_prefix("#"), context_schema)
49
+ else
50
+ @resolver.resolve(ref_string, context_schema)
51
+ end
52
+ end
53
+
54
+ def validate!
55
+ errors = []
56
+ seen_refs = Set.new
57
+
58
+ @schemas.each do |name, schema|
59
+ collect_refs(schema, name, errors, seen_refs, "")
60
+ end
61
+
62
+ raise ValidationError, errors.join("\n") if errors.any?
63
+
64
+ true
65
+ end
66
+
67
+ def valid?
68
+ validate! rescue false
69
+ end
70
+
71
+ def all_definitions
72
+ @schemas.flat_map do |_name, schema|
73
+ schema.definition_entries
74
+ end
75
+ end
76
+
77
+ def all_properties
78
+ @schemas.flat_map do |_name, schema|
79
+ schema.property_entries
80
+ end
81
+ end
82
+
83
+ def validation_errors
84
+ errors = []
85
+ @schemas.each do |name, schema|
86
+ collect_refs(schema, name, errors, Set.new, "")
87
+ end
88
+ errors
89
+ end
90
+
91
+ private
92
+
93
+ def self.infer_base_dir(paths)
94
+ return nil if paths.empty?
95
+
96
+ File.dirname(paths.first)
97
+ end
98
+
99
+ def resolve_file_ref(ref, context_schema)
100
+ return nil unless context_schema
101
+
102
+ target_file = ref.sub(%r{^\./}, "")
103
+ schema = find_schema_by_filename(target_file)
104
+ return schema if schema
105
+
106
+ # Try loading from base_dir
107
+ if @base_dir
108
+ path = File.join(@base_dir, target_file)
109
+ if File.exist?(path)
110
+ loaded = Schema.from_json(File.read(path))
111
+ add(File.basename(target_file, ".*"), loaded, path)
112
+ return loaded
113
+ end
114
+ end
115
+
116
+ nil
117
+ end
118
+
119
+ def resolve_remote_ref(ref)
120
+ return nil
121
+
122
+ # Remote refs are not resolved — would need HTTP fetching
123
+ end
124
+
125
+ def resolve_anchor_ref(anchor, context_schema)
126
+ return nil unless context_schema
127
+
128
+ find_anchor(context_schema, anchor)
129
+ end
130
+
131
+ def find_anchor(schema, anchor)
132
+ return schema if schema.dollar_anchor == anchor
133
+
134
+ [
135
+ schema.property_entries, schema.definition_entries,
136
+ schema.pattern_property_entries, schema.all_of,
137
+ schema.any_of, schema.one_of
138
+ ].each do |entries|
139
+ entries.each do |entry|
140
+ next if entry.nil?
141
+
142
+ child = entry.is_a?(PropertyEntry) ? entry.schema : entry
143
+ next if child.nil?
144
+
145
+ found = find_anchor(child, anchor)
146
+ return found if found
147
+ end
148
+ end
149
+
150
+ [schema.items, schema.not_schema, schema.if_schema,
151
+ schema.then_schema, schema.else_schema].each do |child|
152
+ next if child.nil?
153
+
154
+ found = find_anchor(child, anchor)
155
+ return found if found
156
+ end
157
+
158
+ nil
159
+ end
160
+
161
+ def find_schema_by_filename(filename)
162
+ # Check if we already loaded this file
163
+ @schemas.each do |name, schema|
164
+ return schema if name == File.basename(filename, ".*")
165
+ end
166
+
167
+ # Check file_paths mapping
168
+ return nil unless @file_paths&.key?(filename)
169
+
170
+ name = File.basename(filename, ".*")
171
+ @schemas[name]
172
+ end
173
+
174
+ def collect_refs(schema, source_name, errors, seen_refs, path)
175
+ ref = schema.dollar_ref
176
+ if ref && !seen_refs.include?("#{source_name}:#{path}:#{ref}")
177
+ seen_refs.add("#{source_name}:#{path}:#{ref}")
178
+ resolved = resolve_ref(ref, @schemas[source_name])
179
+ errors << "#{source_name}#{path}: unresolvable $ref '#{ref}'" if resolved.nil? && ref.start_with?("#/", "./")
180
+ end
181
+
182
+ children = [
183
+ [:property_entries, schema.property_entries],
184
+ [:definition_entries, schema.definition_entries],
185
+ [:pattern_property_entries, schema.pattern_property_entries],
186
+ ]
187
+ children.each do |key, entries|
188
+ entries.each do |entry|
189
+ collect_refs(entry.schema, source_name, errors, seen_refs, "#{path}/#{key}/#{entry.name}")
190
+ end
191
+ end
192
+
193
+ schema.all_of.each_with_index { |s, i| collect_refs(s, source_name, errors, seen_refs, "#{path}/allOf[#{i}]") }
194
+ schema.any_of.each_with_index { |s, i| collect_refs(s, source_name, errors, seen_refs, "#{path}/anyOf[#{i}]") }
195
+ schema.one_of.each_with_index { |s, i| collect_refs(s, source_name, errors, seen_refs, "#{path}/oneOf[#{i}]") }
196
+
197
+ single_children = {
198
+ items: schema.items,
199
+ not_schema: schema.not_schema,
200
+ if_schema: schema.if_schema,
201
+ then_schema: schema.then_schema,
202
+ else_schema: schema.else_schema,
203
+ }
204
+ single_children.each do |attr, child|
205
+ collect_refs(child, source_name, errors, seen_refs, "#{path}/#{attr}") if child
206
+ end
207
+
208
+ schema.links.each do |link|
209
+ collect_refs(link.schema, source_name, errors, seen_refs, "#{path}/link.schema") if link.schema
210
+ collect_refs(link.target_schema, source_name, errors, seen_refs, "#{path}/link.target_schema") if link.target_schema
211
+ end
212
+ end
213
+
214
+ class ValidationError < StandardError; end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ module Spa
6
+ class Generator
7
+ def initialize(schema_set, output_path, metadata: Metadata.new, strategy: nil)
8
+ @schema_set = schema_set
9
+ @output_path = output_path
10
+ @metadata = metadata
11
+ @strategy = strategy || VueInlinedStrategy.new(@output_path)
12
+ end
13
+
14
+ def generate
15
+ document = SpaBuilder.new(@schema_set, metadata: @metadata).build
16
+ json_data = document.to_json
17
+ @strategy.write(json_data)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ module Spa
6
+ class Metadata < Base
7
+ attribute :title, :string
8
+ attribute :version, :string
9
+ attribute :description, :string
10
+ attribute :base_url, :string
11
+ attribute :theme, :string, default: "light"
12
+
13
+ json do
14
+ map "title", to: :title
15
+ map "version", to: :version
16
+ map "description", to: :description
17
+ map "baseUrl", to: :base_url
18
+ map "theme", to: :theme
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Jsonschema
5
+ module Spa
6
+ class OutputStrategy
7
+ def initialize(output_path)
8
+ @output_path = output_path
9
+ end
10
+
11
+ def write(_json_data)
12
+ raise NotImplementedError
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end