Module: RSMP::Schema

Defined in:
lib/rsmp/schema.rb,
lib/rsmp/schema_error.rb,
lib/rsmp/schema/message_resolution.rb

Overview

Resolves SXL schemas from message code ownership.

Defined Under Namespace

Classes: AmbiguousMessageCodeError, Error, UnknownMessageCodeError, UnknownSchemaError, UnknownSchemaTypeError, UnknownSchemaVersionError

Constant Summary collapse

MESSAGE_CODE_EXTRACTORS =
{
  'StatusRequest' => ->(message) { status_codes(message) },
  'StatusSubscribe' => ->(message) { status_codes(message) },
  'StatusUnsubscribe' => ->(message) { status_codes(message) },
  'StatusResponse' => ->(message) { status_codes(message) },
  'StatusUpdate' => ->(message) { status_codes(message) },
  'CommandRequest' => ->(message) { request_command_codes(message) },
  'CommandResponse' => ->(message) { response_command_codes(message) },
  'Alarm' => ->(message) { alarm_codes(message) }
}.freeze
MESSAGE_CODE_KINDS =
{
  'StatusRequest' => :statuses,
  'StatusSubscribe' => :statuses,
  'StatusUnsubscribe' => :statuses,
  'StatusResponse' => :statuses,
  'StatusUpdate' => :statuses,
  'CommandRequest' => :commands,
  'CommandResponse' => :commands,
  'Alarm' => :alarms
}.freeze

Class Method Summary collapse

Class Method Details

.alarm_codes(message) ⇒ Object



43
44
45
# File 'lib/rsmp/schema/message_resolution.rb', line 43

def self.alarm_codes(message)
  [message['aCId']].compact
end

.core_message_type?(message) ⇒ Boolean

Returns:

  • (Boolean)


231
232
233
234
235
236
237
238
239
240
241
242
# File 'lib/rsmp/schema.rb', line 231

def self.core_message_type?(message)
  type = message['type']
  %w[
    MessageAck
    MessageNotAck
    Version
    ComponentList
    AggregatedStatus
    AggregatedStatusRequest
    Watchdog
  ].include?(type)
end

.core_versionsObject

get array of core schema versions



78
79
80
# File 'lib/rsmp/schema.rb', line 78

def self.core_versions
  versions :core
end

.earliest_core_versionObject

get earliest core schema version



83
84
85
# File 'lib/rsmp/schema.rb', line 83

def self.earliest_core_version
  earliest_version :core
end

.earliest_version(type) ⇒ Object

get earliest schema version for a particular schema type



99
100
101
102
# File 'lib/rsmp/schema.rb', line 99

def self.earliest_version(type)
  schemas = find_schemas!(type).keys
  sort_versions(schemas).first
end

.ensure_schema_type_available(type, force) ⇒ Object



41
42
43
# File 'lib/rsmp/schema.rb', line 41

def self.ensure_schema_type_available(type, force)
  raise "Schema type #{type} already loaded" if @schemas[type] && force != true
end

.find_schema(type, version, options = {}) ⇒ Object

find schema for a particular schema and version return nil if not found

Raises:

  • (ArgumentError)


148
149
150
151
152
153
154
155
156
157
# File 'lib/rsmp/schema.rb', line 148

def self.find_schema(type, version, options = {})
  raise ArgumentError, 'version missing' unless version

  version = sanitize_version version if options[:lenient]
  if version
    schemas = find_schemas type
    return schemas[version] if schemas
  end
  nil
end

.find_schema!(type, version, options = {}) ⇒ Object

find schema for a particular schema and version raise error if not found

Raises:

  • (ArgumentError)


176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/rsmp/schema.rb', line 176

def self.find_schema!(type, version, options = {})
  schema = find_schema type, version, options
  raise ArgumentError, 'version missing' unless version

  version = sanitize_version version if options[:lenient]
  if version
    schemas = find_schemas! type
    schema = schemas[version]
    return schema if schema
  end
  raise UnknownSchemaVersionError, "Unknown schema version #{type} #{version}"
end

.find_schemas(type) ⇒ Object

find schemas versions for particular schema type return nil if type not found

Raises:

  • (ArgumentError)


131
132
133
134
135
# File 'lib/rsmp/schema.rb', line 131

def self.find_schemas(type)
  raise ArgumentError, 'type missing' unless type

  @schemas[type.to_sym]
end

.find_schemas!(type) ⇒ Object

find schemas versions for particular schema type raise error if not found



139
140
141
142
143
144
# File 'lib/rsmp/schema.rb', line 139

def self.find_schemas!(type)
  schemas = find_schemas type
  raise UnknownSchemaTypeError, "Unknown schema type #{type}" unless schemas

  schemas
end

.latest_core_versionObject

get latesty core schema version



88
89
90
# File 'lib/rsmp/schema.rb', line 88

def self.latest_core_version
  latest_version :core
end

.latest_version(type) ⇒ Object

get latest schema version for a particular schema type



105
106
107
108
# File 'lib/rsmp/schema.rb', line 105

def self.latest_version(type)
  schemas = find_schemas!(type).keys
  sort_versions(schemas).last
end

.load_schema_type(type, type_path, force: false) ⇒ Object

load an schema from a folder. schemas are organized by version, and contain json schema files, with the entry point being rsmp.jspon, eg: tlc

 1.0.7
   rsmp.json
   other jon schema files...
 1.0.8
 ...

an error is raised if the schema type already exists, and force is not set to true


31
32
33
34
35
36
37
38
39
# File 'lib/rsmp/schema.rb', line 31

def self.load_schema_type(type, type_path, force: false)
  type = type.to_sym
  ensure_schema_type_available(type, force)

  @schemas[type] = {}
  @schema_paths ||= {}
  @schema_paths[type] = {}
  schema_version_paths(type_path).each { |schema_path| load_schema_version(type, schema_path) }
end

.load_schema_version(type, schema_path) ⇒ Object



49
50
51
52
53
54
55
56
# File 'lib/rsmp/schema.rb', line 49

def self.load_schema_version(type, schema_path)
  version = File.basename(schema_path)
  file_path = File.join(schema_path, 'rsmp.json')
  return unless File.exist? file_path

  @schemas[type][version] = JSONSchemer.schema(Pathname.new(file_path))
  @schema_paths[type][version] = schema_path
end

.matching_sxl_schemas(schemas, kind, codes, options) ⇒ Object



82
83
84
85
86
87
88
# File 'lib/rsmp/schema/message_resolution.rb', line 82

def self.matching_sxl_schemas(schemas, kind, codes, options)
  sxl_schemas(schemas).select do |type, version|
    sxl_defines_codes?(type, version, kind, codes, options)
  rescue UnknownSchemaError
    false
  end
end

.message_code_kind(message) ⇒ Object



47
48
49
# File 'lib/rsmp/schema/message_resolution.rb', line 47

def self.message_code_kind(message)
  MESSAGE_CODE_KINDS[message['type']]
end

.message_code_kind_name(kind) ⇒ Object



62
63
64
65
66
67
68
# File 'lib/rsmp/schema/message_resolution.rb', line 62

def self.message_code_kind_name(kind)
  {
    statuses: 'status',
    commands: 'command',
    alarms: 'alarm'
  }.fetch(kind) { kind.to_s.delete_suffix('s') }
end

.message_codes(message) ⇒ Object



26
27
28
29
# File 'lib/rsmp/schema/message_resolution.rb', line 26

def self.message_codes(message)
  extractor = MESSAGE_CODE_EXTRACTORS[message['type']]
  extractor ? extractor.call(message).uniq : []
end

.raise_if_ambiguous_sxl_match(codes, matches) ⇒ Object



99
100
101
102
# File 'lib/rsmp/schema/message_resolution.rb', line 99

def self.raise_if_ambiguous_sxl_match(codes, matches)
  names = matches.map { |type, version| "#{type} #{version}" }.join(', ')
  raise AmbiguousMessageCodeError, "Message code(s) #{codes.join(', ')} match multiple accepted SXLs: #{names}"
end

.raise_if_no_sxl_match(kind, codes) ⇒ Object



94
95
96
97
# File 'lib/rsmp/schema/message_resolution.rb', line 94

def self.raise_if_no_sxl_match(kind, codes)
  raise UnknownMessageCodeError,
        "No accepted SXL defines #{message_code_kind_name(kind)} code(s) #{codes.join(', ')}"
end

.remove_schema_type(type) ⇒ Object

remove a schema type



59
60
61
62
63
# File 'lib/rsmp/schema.rb', line 59

def self.remove_schema_type(type)
  type = type.to_sym
  schemas.delete type
  @schema_paths&.delete type
end

.request_command_codes(message) ⇒ Object



35
36
37
# File 'lib/rsmp/schema/message_resolution.rb', line 35

def self.request_command_codes(message)
  (message['arg'] || []).map { |item| item['cCI'] }.compact
end

.resolve_sxl(message, schemas:, **options) ⇒ Object



70
71
72
73
74
75
76
77
78
79
80
# File 'lib/rsmp/schema/message_resolution.rb', line 70

def self.resolve_sxl(message, schemas:, **options)
  kind = message_code_kind(message)
  codes = message_codes(message)
  return nil unless kind && codes.any?

  matches = matching_sxl_schemas(schemas, kind, codes, options)
  raise_if_no_sxl_match(kind, codes) if matches.empty?
  raise_if_ambiguous_sxl_match(codes, matches) if matches.size > 1

  matches.first
end

.response_command_codes(message) ⇒ Object



39
40
41
# File 'lib/rsmp/schema/message_resolution.rb', line 39

def self.response_command_codes(message)
  (message['rvs'] || []).map { |item| item['cCI'] }.compact
end

.sanitize_version(version) ⇒ Object

get major.minor.patch part of a version string, where patch is optional ignore trailing characters, e.g.

3.1.3.32A => 3.1.3
3.1A3r3 >= 3.1

return nil if string doesn’t match



164
165
166
167
168
169
170
171
172
# File 'lib/rsmp/schema.rb', line 164

def self.sanitize_version(version)
  # match normal semver z.y.z format
  if (matched = /^\d+\.\d+\.\d+/.match(version))
    matched.to_s
  # match x.y format, and add patch version zero to get z.y.0
  elsif (matched = /^\d+\.\d+/.match(version))
    "#{matched}.0"
  end
end

.schema?(type, version, options = {}) ⇒ Boolean

true if a particular schema type and version found

Returns:

  • (Boolean)


190
191
192
# File 'lib/rsmp/schema.rb', line 190

def self.schema?(type, version, options = {})
  find_schema(type, version, options) != nil
end

.schema_typesObject

get schemas types



66
67
68
# File 'lib/rsmp/schema.rb', line 66

def self.schema_types
  schemas.keys
end

.schema_version_paths(type_path) ⇒ Object



45
46
47
# File 'lib/rsmp/schema.rb', line 45

def self.schema_version_paths(type_path)
  Dir.glob("#{type_path}/*").select { |path| File.directory? path }
end

.schemasObject

get all schemas, oganized by type and version



71
72
73
74
75
# File 'lib/rsmp/schema.rb', line 71

def self.schemas
  raise 'No schemas available, perhaps Schema.setup was never called?' unless @schemas

  @schemas
end

.setupObject



11
12
13
14
15
16
17
18
19
# File 'lib/rsmp/schema.rb', line 11

def self.setup
  @schemas = {}
  @schema_paths = {}
  schemas_path = File.expand_path(File.join(__dir__, '..', '..', 'schemas'))
  Dir.glob("#{schemas_path}/*").select { |f| File.directory? f }.each do |type_path|
    type = File.basename(type_path).to_sym
    load_schema_type type, type_path
  end
end

.sort_versions(versions) ⇒ Object

sort version strings



125
126
127
# File 'lib/rsmp/schema.rb', line 125

def self.sort_versions(versions)
  versions.sort_by { |k| Gem::Version.new(k) }
end

.status_catalogue(type, version) ⇒ Object

return a catalogue of statuses for a particular schema type and version returns a hash of { status_code_id_sym => [arg_name_sym, …] } raises an error if the schema type/version is not found, or has no sxl.yaml



215
216
217
218
219
# File 'lib/rsmp/schema.rb', line 215

def self.status_catalogue(type, version)
  sxl_catalogue(type, version, :statuses).transform_keys(&:to_sym).transform_values do |status|
    (status['arguments'] || {}).keys.map(&:to_sym)
  end
end

.status_codes(message) ⇒ Object



31
32
33
# File 'lib/rsmp/schema/message_resolution.rb', line 31

def self.status_codes(message)
  (message['sS'] || []).map { |item| item['sCI'] }.compact
end

.sxl_catalogue(type, version, kind) ⇒ Object



221
222
223
224
225
226
227
228
229
# File 'lib/rsmp/schema.rb', line 221

def self.sxl_catalogue(type, version, kind)
  find_schema! type, version
  schema_path = @schema_paths&.dig(type.to_sym, version)
  yaml_path = File.join(schema_path, 'sxl.yaml') if schema_path
  raise "No sxl.yaml for #{type} #{version}" unless yaml_path && File.exist?(yaml_path)

  sxl = RSMP::Convert::Import::YAML.read(yaml_path)
  sxl.fetch(kind)
end

.sxl_defines_codes?(type, version, kind, codes, options) ⇒ Boolean

Returns:

  • (Boolean)


51
52
53
54
55
56
57
58
59
60
# File 'lib/rsmp/schema/message_resolution.rb', line 51

def self.sxl_defines_codes?(type, version, kind, codes, options)
  version = sanitize_version(version.to_s) if options[:lenient]
  catalogue = sxl_catalogue(type, version, kind)
  prefix = sxl_prefix(type, version, options)
  codes.all? do |code|
    unprefixed = prefix && code.start_with?(prefix) ? code[prefix.length..] : code
    catalogue.key?(code) || catalogue.key?(code.to_sym) ||
      catalogue.key?(unprefixed) || catalogue.key?(unprefixed.to_sym)
  end
end

.sxl_metadata(type, version, options = {}) ⇒ Object



194
195
196
197
198
199
200
201
202
203
204
205
206
# File 'lib/rsmp/schema.rb', line 194

def self.(type, version, options = {})
  version = sanitize_version version if options[:lenient]
  find_schema! type, version

  path = @schema_paths&.dig(type.to_sym, version)
  return {} unless path

  yaml_path = File.join(path, 'sxl.yaml')
  return YAML.load_file(yaml_path).fetch('meta', {}) if File.exist?(yaml_path)

  json_path = File.join(path, 'rsmp.json')
  File.exist?(json_path) ? JSON.parse(File.read(json_path)) : {}
end

.sxl_prefix(type, version, options = {}) ⇒ Object



208
209
210
# File 'lib/rsmp/schema.rb', line 208

def self.sxl_prefix(type, version, options = {})
  (type, version, options)['prefix']
end

.sxl_schemas(schemas) ⇒ Object



90
91
92
# File 'lib/rsmp/schema/message_resolution.rb', line 90

def self.sxl_schemas(schemas)
  schemas.reject { |type, _version| type.to_sym == :core }
end

.validate(message, schemas, options = {}) ⇒ Object

validate using core and optional SXL schemas. Core must pass. SXL-defined messages pass if at least one SXL schema passes. returns nil if validation succeeds, otherwise returns an array of errors.

Raises:

  • (ArgumentError)


277
278
279
280
281
282
283
284
285
286
287
288
# File 'lib/rsmp/schema.rb', line 277

def self.validate(message, schemas, options = {})
  raise ArgumentError, 'message missing' unless message
  raise ArgumentError, 'schemas missing' unless schemas
  raise ArgumentError, 'schemas must be a Hash' unless schemas.is_a?(Hash)
  raise ArgumentError, 'schemas cannot be empty' unless schemas.any?

  errors = validate_core(message, schemas, options)
  errors.concat validate_sxls(message, schemas, options) if errors.empty?
  return nil if errors.empty?

  errors
end

.validate_core(message, schemas, options) ⇒ Object

Raises:

  • (ArgumentError)


244
245
246
247
248
249
250
# File 'lib/rsmp/schema.rb', line 244

def self.validate_core(message, schemas, options)
  core_version = schemas[:core] || schemas['core']
  raise ArgumentError, 'schemas must include core' unless core_version

  schema = find_schema! :core, core_version, options
  validate_using_schema(message, schema)
end

.validate_sxls(message, schemas, options) ⇒ Object



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# File 'lib/rsmp/schema.rb', line 252

def self.validate_sxls(message, schemas, options)
  sxl_schemas = schemas.reject { |type, _version| type.to_sym == :core }
  return [] if sxl_schemas.empty? || core_message_type?(message)

  resolved = resolve_sxl(message, schemas: schemas, **options)
  if resolved
    type, version = resolved
    schema = find_schema! type, version, options
    return validate_using_schema(message, schema)
  end

  all_errors = []
  sxl_schemas.each do |type, version|
    schema = find_schema! type, version, options
    errors = validate_using_schema(message, schema)
    return [] if errors.empty?

    all_errors.concat errors
  end
  all_errors
end

.validate_using_schema(message, schema) ⇒ Object

validate an rsmp messages using a schema object

Raises:

  • (ArgumentError)


111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/rsmp/schema.rb', line 111

def self.validate_using_schema(message, schema)
  raise ArgumentError, 'message missing' unless message
  raise ArgumentError, 'schema missing' unless schema

  if schema.valid? message
    []
  else
    schema.validate(message).map do |item|
      [item['data_pointer'], item['type'], item['details']]
    end
  end
end

.versions(type) ⇒ Object

get array of schema versions for a particular schema type



93
94
95
96
# File 'lib/rsmp/schema.rb', line 93

def self.versions(type)
  schemas = find_schemas!(type).keys
  sort_versions(schemas)
end