Module: RSMP::Convert::Export::JSONSchema

Defined in:
lib/rsmp/convert/export/json_schema.rb,
lib/rsmp/convert/export/json_schema/index.rb,
lib/rsmp/convert/export/json_schema/items.rb,
lib/rsmp/convert/export/json_schema/values.rb,
lib/rsmp/convert/export/json_schema/outputs.rb

Overview

Converts SXL definitions to JSON Schema files.

Constant Summary collapse

JSON_OPTIONS =
{
  array_nl: "\n",
  object_nl: "\n",
  indent: '  ',
  space_before: ' ',
  space: ' ',
  ascii_only: true
}.freeze
DIRECT_JSON_TYPES =
{
  'boolean' => 'boolean',
  'integer' => 'integer',
  'number' => 'number',
  'object' => 'object',
  'null' => 'null'
}.freeze
DEFINITION_REFS =
{
  'boolean_as_string' => '../defs/definitions.json#/boolean',
  'timestamp' => '../defs/definitions.json#/timestamp',
  'integer_as_string' => '../defs/definitions.json#/integer',
  'long_as_string' => '../defs/definitions.json#/integer'
}.freeze
GUARDS_JSON =
{
  '$schema' => 'https://json-schema.org/draft/2020-12/schema',
  '$defs' => {
    'q_unknown_or_undefined' => {
      'allOf' => [
        { 'required' => ['q'] },
        { 'properties' => { 'q' => { 'enum' => %w[undefined unknown] } } }
      ]
    },
    'age_unknown_or_undefined' => {
      'allOf' => [
        { 'required' => ['age'] },
        { 'properties' => { 'age' => { 'enum' => %w[undefined unknown] } } }
      ]
    }
  }
}.freeze

Class Method Summary collapse

Class Method Details

.argument_array_descriptor(argument) ⇒ Object



49
50
51
52
53
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 49

def self.argument_array_descriptor(argument)
  descriptor = { 'type' => argument['type'] }
  descriptor['items'] = typed_arguments(argument['items']) if argument['items'].is_a?(Hash)
  descriptor
end

.argument_object_descriptor(argument) ⇒ Object



55
56
57
58
59
60
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 55

def self.argument_object_descriptor(argument)
  descriptor = { 'type' => argument['type'] }
  properties = argument['properties'] || argument['items']
  descriptor['properties'] = typed_arguments(properties) if properties.is_a?(Hash)
  descriptor
end

.argument_type_descriptor(argument) ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 37

def self.argument_type_descriptor(argument)
  type = argument['type']
  case type
  when 'array'
    argument_array_descriptor(argument)
  when 'object'
    argument_object_descriptor(argument)
  else
    type
  end
end

.build_default_item(item, arguments, property_key) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/rsmp/convert/export/json_schema/items.rb', line 38

def self.build_default_item(item, arguments, property_key)
  rules = arguments.map do |key, argument|
    {
      'if' => { 'properties' => { 'n' => { 'const' => key } } },
      'then' => { 'properties' => { property_key => build_value(argument) } }
    }
  end
  schema = {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'description' => item['description'],
    'properties' => { 'n' => { 'enum' => arguments.keys.sort } },
    'allOf' => rules
  }
  return schema unless property_key == 'v'

  schema.delete 'allOf'
  schema.merge(
    'if' => { '$ref' => '../defs/guards.json#/$defs/age_unknown_or_undefined' },
    'then' => {},
    'else' => { 'allOf' => rules }
  )
end

.build_item(item, property_key: 'v') ⇒ Object

convert yaml alarm/status/command item to corresponding json schema



7
8
9
10
11
12
# File 'lib/rsmp/convert/export/json_schema/items.rb', line 7

def self.build_item(item, property_key: 'v')
  arguments = item['arguments']
  return simple_item(item) unless arguments

  property_key == 's' ? build_status_item(item, arguments) : build_default_item(item, arguments, property_key)
end

.build_json_array(item, out) ⇒ Object

convert an yaml item with type: array to json schema



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 59

def self.build_json_array(item, out)
  required = item.reject { |_k, v| v['optional'] == true }.keys.sort
  out.merge!({
               'type' => 'array',
               'items' => {
                 'type' => 'object',
                 'required' => required,
                 'unevaluatedProperties' => false
               }
             })
  out['items']['properties'] = {}
  item.each_pair do |key, v|
    out['items']['properties'][key] = build_value(v)
  end
  out
end

.build_json_array_type(item, out) ⇒ Object



45
46
47
48
49
50
51
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 45

def self.build_json_array_type(item, out)
  if item['items']
    build_json_array item['items'], out
  else
    out['type'] = 'array'
  end
end

.build_number_as_string_type(out) ⇒ Object



53
54
55
56
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 53

def self.build_number_as_string_type(out)
  out['type'] = 'string'
  out['pattern'] = '^-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$'
end

.build_status_item(item, arguments) ⇒ Object



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/rsmp/convert/export/json_schema/items.rb', line 21

def self.build_status_item(item, arguments)
  branches = arguments.map do |key, argument|
    {
      'if' => { 'properties' => { 'n' => { 'const' => key } } },
      'then' => { 'properties' => { 's' => build_value(argument) } }
    }
  end
  {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'description' => item['description'],
    'properties' => { 'n' => { 'enum' => arguments.keys.sort } },
    'if' => { '$ref' => '../defs/guards.json#/$defs/q_unknown_or_undefined' },
    'then' => {},
    'else' => { 'allOf' => branches }
  }
end

.build_value(item) ⇒ Object

convert a yaml item to json schema



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 22

def self.build_value(item)
  out = {}
  out['description'] = item['description'] if item['description']
  if item['type'] =~ /_list(_as_string)?$/
    handle_string_list item, out
  else
    handle_types item, out
    handle_enum item, out
    handle_pattern item, out
  end
  wrap_refs out
end

.command_argument_contains_rule(command_code, name) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 139

def self.command_argument_contains_rule(command_code, name)
  {
    'contains' => {
      'required' => %w[cCI n],
      'properties' => {
        'cCI' => { 'const' => command_code },
        'n' => { 'const' => name }
      }
    }
  }
end

.command_request_arg_schema(items) ⇒ Object



112
113
114
115
116
117
118
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 112

def self.command_request_arg_schema(items)
  schema = { '$ref' => 'commands.json' }
  required_rules = command_required_argument_rules(items)
  return schema if required_rules.empty?

  { 'allOf' => [schema] + required_rules }
end

.command_requests_schema(items) ⇒ Object



98
99
100
101
102
103
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 98

def self.command_requests_schema(items)
  {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'properties' => { 'arg' => command_request_arg_schema(items) }
  }
end

.command_required_argument_rules(items) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 120

def self.command_required_argument_rules(items)
  items.keys.sort.filter_map do |key|
    required = required_argument_names(items[key])
    next if required.empty?

    {
      'if' => {
        'contains' => {
          'required' => ['cCI'],
          'properties' => { 'cCI' => { 'const' => key } }
        }
      },
      'then' => {
        'allOf' => required.map { |name| command_argument_contains_rule(key, name) }
      }
    }
  end
end

.command_responses_schemaObject



105
106
107
108
109
110
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 105

def self.command_responses_schema
  {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'properties' => { 'rvs' => { '$ref' => 'commands.json' } }
  }
end

.commands_schema(items) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 84

def self.commands_schema(items)
  list = [{ 'properties' => { 'cCI' => { 'enum' => items.keys.sort } } }]
  items.keys.sort.each do |key|
    list << {
      'if' => { 'required' => ['cCI'], 'properties' => { 'cCI' => { 'const' => key } } },
      'then' => { '$ref' => "#{key}.json" }
    }
  end
  {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'items' => { 'allOf' => list }
  }
end

.definitions_source(sxl) ⇒ Object

Path to definitions.json for the fallback bundled core schema version



36
37
38
39
40
41
42
# File 'lib/rsmp/convert/export/json_schema.rb', line 36

def self.definitions_source(sxl)
  version = minimum_core_version(sxl)
  path = File.expand_path("../../../../schemas/core/#{version}/definitions.json", __dir__)
  raise "Missing core definitions for RSMP #{version}" unless File.exist?(path)

  path
end

.enum_keys(item) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 118

def self.enum_keys(item)
  case item['values']
  when Hash
    validate_hash_values! item
    item['values'].keys.sort
  when Array
    item['values'].sort
  else
    raise 'Error: Values must be specified as either a Hash or an Array, ' \
          "got #{item['values'].class}"
  end
end

.generate(sxl) ⇒ Object

generate the json schema from a string containing yaml



45
46
47
48
49
50
51
52
53
# File 'lib/rsmp/convert/export/json_schema.rb', line 45

def self.generate(sxl)
  out = {}
  output_root out, sxl[:meta]
  output_alarms out, sxl[:alarms]
  output_statuses out, sxl[:statuses]
  output_commands out, sxl[:commands]
  output_sxl_index out, sxl
  out
end

.handle_enum(item, out) ⇒ Object

convert yaml values to json schema enum



105
106
107
108
109
110
111
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 105

def self.handle_enum(item, out)
  return unless item['values']

  values = enum_keys(item)
  values = stringify_values(values) if string_type? item
  out['enum'] = values
end

.handle_pattern(item, out) ⇒ Object

convert yaml pattern to json schema



145
146
147
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 145

def self.handle_pattern(item, out)
  out['pattern'] = item['pattern'] if item['pattern']
end

.handle_string_list(item, out) ⇒ Object

convert a yaml item with list: true to json schema



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 82

def self.handle_string_list(item, out)
  case item['type']
  when 'boolean_list', 'boolean_list_as_string'
    out['$ref'] = '../defs/definitions.json#/boolean_list'
  when 'integer_list', 'integer_list_as_string'
    out['$ref'] = '../defs/definitions.json#/integer_list'
  when 'number_list', 'number_list_as_string'
    out['$ref'] = '../defs/definitions.json#/number_list'
  when 'string_list', 'string_list_as_string'
    out['$ref'] = '../defs/definitions.json#/string_list'
  else
    raise "Error: List of #{item['type']} is not supported: #{item.inspect}"
  end

  if item['values']
    value_list = item['values'].keys.join('|')
    out['pattern'] = /(?-mix:^(#{value_list})(?:,(#{value_list}))*$)/
  end

  handle_pattern item, out
end

.handle_types(item, out) ⇒ Object

convert an item which is not a string-list, to json schema



36
37
38
39
40
41
42
43
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 36

def self.handle_types(item, out)
  type = item['type']
  return build_json_array_type(item, out) if type == 'array'
  return build_number_as_string_type(out) if type == 'number_as_string'
  return out['$ref'] = DEFINITION_REFS[type] if DEFINITION_REFS.key?(type)

  out['type'] = DIRECT_JSON_TYPES.fetch(type, 'string')
end

.index_item(item) ⇒ Object



21
22
23
24
25
26
27
28
29
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 21

def self.index_item(item)
  arguments = item['arguments'] || {}
  entry = {}
  required = typed_arguments(arguments.reject { |_name, argument| argument['optional'] == true })
  optional = typed_arguments(arguments.select { |_name, argument| argument['optional'] == true })
  entry['required'] = required unless required.empty?
  entry['optional'] = optional unless optional.empty?
  entry
end

.index_items(items) ⇒ Object



15
16
17
18
19
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 15

def self.index_items(items)
  items.keys.sort.to_h do |key|
    [key, index_item(items[key])]
  end
end

.minimum_core_version(sxl) ⇒ Object



31
32
33
# File 'lib/rsmp/convert/export/json_schema.rb', line 31

def self.minimum_core_version(sxl)
  sxl.dig(:meta, 'minimum_core_version') || RSMP::Schema.latest_core_version
end

.output_alarm(out, key, item) ⇒ Object

convert an alarm to json schema



44
45
46
47
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 44

def self.output_alarm(out, key, item)
  json = build_item item
  out["alarms/#{key}.json"] = output_json json
end

.output_alarms(out, items) ⇒ Object

convert alarms to json schema



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 25

def self.output_alarms(out, items)
  list = items.keys.sort.map do |key|
    {
      'if' => { 'required' => ['aCId'], 'properties' => { 'aCId' => { 'const' => key } } },
      'then' => { '$ref' => "#{key}.json" }
    }
  end
  json = {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'properties' => {
      'aCId' => { 'enum' => items.keys.sort },
      'rvs' => { 'items' => { 'allOf' => list } }
    }
  }
  out['alarms/alarms.json'] = output_json json
  items.each_pair { |key, item| output_alarm out, key, item }
end

.output_command(out, key, item) ⇒ Object

convert a command to json schema



156
157
158
159
160
161
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 156

def self.output_command(out, key, item)
  json = build_item item
  json['properties'] ||= {}
  json['properties']['cO'] = { 'const' => item['command'] }
  out["commands/#{key}.json"] = output_json json
end

.output_commands(out, items) ⇒ Object

convert commands to json schema



75
76
77
78
79
80
81
82
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 75

def self.output_commands(out, items)
  out['defs/guards.json'] ||= output_json(GUARDS_JSON)
  out['commands/commands.json'] = output_json commands_schema(items)
  out['commands/command_requests.json'] = output_json command_requests_schema(items)
  out['commands/command_responses.json'] = output_json command_responses_schema

  items.each_pair { |key, item| output_command out, key, item }
end

.output_json(item) ⇒ Object



27
28
29
# File 'lib/rsmp/convert/export/json_schema.rb', line 27

def self.output_json(item)
  JSON.generate(item, JSON_OPTIONS)
end

.output_root(out, meta) ⇒ Object

output the json schema root



164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 164

def self.output_root(out, meta)
  json = {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'name' => meta['name'],
    'description' => meta['description'],
    'version' => meta['version'],
    'allOf' => root_type_rules
  }
  json['prefix'] = meta['prefix'] if meta['prefix']
  json['minimum_core_version'] = meta['minimum_core_version'] if meta['minimum_core_version']
  out['rsmp.json'] = output_json json
end

.output_status(out, key, item) ⇒ Object

convert a status to json schema



69
70
71
72
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 69

def self.output_status(out, key, item)
  json = build_item item, property_key: 's'
  out["statuses/#{key}.json"] = output_json json
end

.output_statuses(out, items) ⇒ Object

convert statuses to json schema



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 50

def self.output_statuses(out, items)
  out['defs/guards.json'] ||= output_json(GUARDS_JSON)

  list = [{ 'properties' => { 'sCI' => { 'enum' => items.keys.sort } } }]
  items.keys.sort.each do |key|
    list << {
      'if' => { 'required' => ['sCI'], 'properties' => { 'sCI' => { 'const' => key } } },
      'then' => { '$ref' => "#{key}.json" }
    }
  end
  json = {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'properties' => { 'sS' => { 'items' => { 'allOf' => list } } }
  }
  out['statuses/statuses.json'] = output_json json
  items.each_pair { |key, item| output_status out, key, item }
end

.output_sxl_index(out, sxl) ⇒ Object



6
7
8
9
10
11
12
13
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 6

def self.output_sxl_index(out, sxl)
  out['sxl_index.json'] = output_json({
                                        'meta' => sxl[:meta],
                                        'statuses' => index_items(sxl[:statuses]),
                                        'commands' => index_items(sxl[:commands]),
                                        'alarms' => index_items(sxl[:alarms])
                                      })
end

.required_argument_names(item) ⇒ Object



151
152
153
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 151

def self.required_argument_names(item)
  (item['arguments'] || {}).reject { |_name, argument| argument['optional'] == true }.keys.sort
end

.root_type_rulesObject



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/rsmp/convert/export/json_schema/outputs.rb', line 177

def self.root_type_rules
  [
    {
      'if' => { 'required' => ['type'], 'properties' => { 'type' => { 'const' => 'CommandRequest' } } },
      'then' => { '$ref' => 'commands/command_requests.json' }
    },
    {
      'if' => { 'required' => ['type'], 'properties' => { 'type' => { 'const' => 'CommandResponse' } } },
      'then' => { '$ref' => 'commands/command_responses.json' }
    },
    {
      'if' => {
        'required' => ['type'],
        'properties' => {
          'type' => { 'enum' => %w[StatusRequest StatusResponse StatusSubscribe StatusUnsubscribe
                                   StatusUpdate] }
        }
      },
      'then' => { '$ref' => 'statuses/statuses.json' }
    },
    {
      'if' => { 'required' => ['type'], 'properties' => { 'type' => { 'const' => 'Alarm' } } },
      'then' => { '$ref' => 'alarms/alarms.json' }
    }
  ]
end

.simple_item(item) ⇒ Object



14
15
16
17
18
19
# File 'lib/rsmp/convert/export/json_schema/items.rb', line 14

def self.simple_item(item)
  {
    '$schema' => 'https://json-schema.org/draft/2020-12/schema',
    'description' => item['description']
  }
end

.string_type?(item) ⇒ Boolean

Returns:

  • (Boolean)


113
114
115
116
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 113

def self.string_type?(item)
  type = item['type'].to_s
  type == 'string' || type.end_with?('_as_string') || %w[base64 timestamp].include?(type)
end

.stringify_values(values) ⇒ Object



140
141
142
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 140

def self.stringify_values(values)
  values.map { |v| v.is_a?(Integer) || v.is_a?(Float) ? v.to_s : v }
end

.typed_arguments(arguments) ⇒ Object



31
32
33
34
35
# File 'lib/rsmp/convert/export/json_schema/index.rb', line 31

def self.typed_arguments(arguments)
  arguments.keys.sort.to_h do |name|
    [name, argument_type_descriptor(arguments[name])]
  end
end

.validate_hash_values!(item) ⇒ Object



131
132
133
134
135
136
137
138
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 131

def self.validate_hash_values!(item)
  item['values'].each_pair do |k, v|
    next unless ['', nil].include?(v)

    raise "Error: '#{k}' has empty value in #{item}. " \
          '(When using a hash to specify \'values\', the hash values cannot be empty.)'
  end
end

.wrap_refs(out) ⇒ Object

JSON Schema 2020-12 allows combining $ref with other properties directly



77
78
79
# File 'lib/rsmp/convert/export/json_schema/values.rb', line 77

def self.wrap_refs(out)
  out
end

.write(sxl, folder) ⇒ Object

convert yaml to json schema and write files to a folder



56
57
58
59
60
61
62
63
64
65
66
67
68
# File 'lib/rsmp/convert/export/json_schema.rb', line 56

def self.write(sxl, folder)
  out = generate sxl
  out.each_pair do |relative_path, str|
    path = File.join(folder, relative_path)
    FileUtils.mkdir_p File.dirname(path)
    File.open(path, 'w+') { |file| file.puts str }
  end
  # Copy definitions.json so each version folder is self-contained
  defs_dest = File.join(folder, 'defs', 'definitions.json')
  FileUtils.mkdir_p File.dirname(defs_dest)
  source = definitions_source(sxl)
  FileUtils.cp source, defs_dest
end