yang_json 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +110 -0
- data/SECURITY.md +12 -0
- data/data/encoding.yml +21 -0
- data/lib/yang_json/core.rb +138 -0
- data/lib/yang_json/schema.rb +137 -0
- data/lib/yang_json/transformer.rb +245 -0
- data/lib/yang_json/types.rb +183 -0
- data/lib/yang_json/version.rb +6 -0
- data/lib/yang_json.rb +62 -0
- data/sig/yang_json.rbs +53 -0
- metadata +118 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fa4c5688935f52cb5b7f4c19617c931412a8292e80ae74172f9038f8ce2b51a1
|
|
4
|
+
data.tar.gz: c37fa39548ca49e450eba98cb78d3d15c84cf64de1387cb35294327ab92e266b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 86e6486fcc19d66b68bc9b65e904f748557e24e5c19dde37fc3c00c0c8c2c27205570b0db297666feac0dda9cf055926bbd30c590a2268c631a8c05a520ee2f7
|
|
7
|
+
data.tar.gz: 78bf06358c8d754cf5614e1ff69f1eca28787c325453390fb576851e65d191d462f99ab1eee926a96f5fe44b6aee028aafe787748b892cff4279c0514bcb5a6f
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# yang_json
|
|
2
|
+
|
|
3
|
+
Read, validate and write [RFC 7951](https://www.rfc-editor.org/rfc/rfc7951) YANG JSON in Ruby, including [RFC 7952](https://www.rfc-editor.org/rfc/rfc7952) metadata. Supply a schema as a Ruby Hash or JSON document. No external schema compiler, YANG parser, or subprocess is required.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "yang_json", "~> 0.1.0"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Ruby 3.3 or later, JRuby and TruffleRuby are tested. Runtime dependencies are `json` (2.19.4–3.x), `bigdecimal` and `base64`; no other gem in this family is required. JSON 2.x and 3.x both reject duplicate object members explicitly.
|
|
12
|
+
|
|
13
|
+
## Schema and data
|
|
14
|
+
|
|
15
|
+
Define the nodes and their types directly, or load the same structure with `Schema.from_json`. See [schema format](docs/jtox-format.md).
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require "yang_json"
|
|
19
|
+
|
|
20
|
+
schema = YangJSON::Schema.new({
|
|
21
|
+
"modules" => { "orchard" => ["o", "urn:example:orchard"] },
|
|
22
|
+
"tree" => { "orchard:grove" => ["container", { "count" => ["leaf", "uint64"] }] },
|
|
23
|
+
"annotations" => {}
|
|
24
|
+
})
|
|
25
|
+
data = YangJSON.decode('{"orchard:grove":{"count":"123"}}', schema)
|
|
26
|
+
data["orchard:grove"]["orchard:count"] # => 123
|
|
27
|
+
YangJSON.encode(data, schema) # => '{"orchard:grove":{"count":"123"}}'
|
|
28
|
+
schema.lookup("/orchard:grove/count").type # => "uint64"
|
|
29
|
+
YangJSON.validate('{"orchard:grove":{"count":123}}', schema).first.pointer
|
|
30
|
+
# => "/orchard:grove/count"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`decode` returns a mutable `YangJSON::Document`, a Hash with `warnings` and `union_members`. All modeled Ruby keys are **module-qualified**, including descendants. `encode` requires that form and writes the minimally qualified names required by RFC 7951. Opaque `anydata`/`anyxml` content retains its wire names. The schema and its nodes are immutable and shareable; conversions do not mutate inputs. `YangJson` is an alias for `YangJSON`.
|
|
34
|
+
|
|
35
|
+
`YangJSON.parse(json, schema: schema)` is an alternate spelling of `decode`. `schema.lookup` accepts a slash-separated path or an array of names; descendants may use simple or qualified names. List nodes expose their qualified `keys`.
|
|
36
|
+
|
|
37
|
+
## Values
|
|
38
|
+
|
|
39
|
+
| YANG type | Ruby value | JSON representation |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| int8/int16/int32, uint8/uint16/uint32 | Integer, within the type's range | Number |
|
|
42
|
+
| int64, uint64 | Integer, within the type's range | Decimal string |
|
|
43
|
+
| decimal64 | BigDecimal, exact scaled 64-bit value | Decimal string |
|
|
44
|
+
| string, enumeration | Unicode String | String |
|
|
45
|
+
| boolean | true / false | true / false |
|
|
46
|
+
| bits | Array of unique names | Whitespace-separated string |
|
|
47
|
+
| binary | ASCII-8BIT String (`bytes.b`) | Standard padded base64 |
|
|
48
|
+
| identityref | Module-qualified String | Local name or qualified name |
|
|
49
|
+
| empty | true | `[null]` |
|
|
50
|
+
| union | Native value of a matching member | Matching member's JSON type |
|
|
51
|
+
| instance-identifier | String | Absolute path with module-name qualification |
|
|
52
|
+
| leafref | Native value of the resolved target type | Target type's representation |
|
|
53
|
+
| anydata | Hash of opaque JSON values | Object subject to RFC 7951 §5.5 |
|
|
54
|
+
| anyxml | Opaque JSON value | I-JSON value |
|
|
55
|
+
|
|
56
|
+
Decimal strings normalize on encoding (`"12.30"` becomes `"12.3"`, negative zero becomes `"0.0"`). Integer signs/leading zeros, bit whitespace and local identity prefixes also normalize. Binary decoding requires canonical padding and zero pad bits. Integer JSON tokens containing fractions/exponents are not accepted as small integer values.
|
|
57
|
+
|
|
58
|
+
Unions try compatible members in schema order, using the JSON type during decoding. Documents remember the selected member at each path, so JSON `1` and `"1"` remain distinct even when both become Ruby Integers. Plain Hash inputs use the first compatible member. Moving values to different paths, reordering arrays, or discarding the Document can discard this original selection; select unambiguous schema members when that distinction matters.
|
|
59
|
+
|
|
60
|
+
Metadata uses `@` inside container/list-entry/anydata objects, or `@module:leaf` beside modeled native leaves, anyxml and leaf-lists. Annotation names always stay qualified, and values use their declared types. Leaf-list metadata arrays preserve null holes and may omit trailing nulls. Registered annotations within opaque anydata are converted; its data values otherwise remain in wire form.
|
|
61
|
+
|
|
62
|
+
## Validation and limits
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
data = YangJSON.decode(json, schema, strict: false)
|
|
66
|
+
data.warnings.each { |violation| warn "#{violation.pointer}: #{violation.message}" }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Strict decoding raises `ValidationError`; lenient decoding retains unknown or invalid values and attaches diagnostics. It does not make invalid configuration safe. Encoding is always strict. Unknown schema fields cannot be serialized until the schema is extended or the fields are removed. Modeled keys normalize in lenient results, so malformed namespace aliases cannot always be preserved independently.
|
|
70
|
+
|
|
71
|
+
`ValidationError#violations` contains `Violation` objects with `pointer`, `message` and `rule`; `ValidationError#pointer` identifies the first failure. Pointers address the input document. `validate` returns violations, including parse failures. `ParseError` covers malformed JSON/schemas and resource limits; `ConversionError` covers generation errors. All inherit from `YangJSON::Error`. Unknown options, nonpositive limits and invalid schema arguments raise `ArgumentError`.
|
|
72
|
+
|
|
73
|
+
| Option | Default | Applies to |
|
|
74
|
+
|---|---:|---|
|
|
75
|
+
| `max_bytes` | 16 MiB | Input/output JSON; aggregate native string/key bytes |
|
|
76
|
+
| `max_depth` | 32 | JSON/native/schema nesting |
|
|
77
|
+
| `max_nodes` | 100,000 | Total values visited |
|
|
78
|
+
| `max_items` | 10,000 | Members of an object or items of an array |
|
|
79
|
+
| `max_binary_bytes` | 1 MiB | Decoded/encoded binary payload |
|
|
80
|
+
|
|
81
|
+
Pass overrides to `decode`, `encode`, `parse`, `validate` or `Schema.from_json`. Integer/decimal strings have an additional 128-character bound. Invalid Unicode, noncharacters, nonfinite numbers and duplicate members always fail, including lenient mode. Opaque integer values are restricted to ±(2^53−1); represent larger numbers as strings. Large objects, cyclic Ruby data and deep nesting are rejected before recursive conversion can exhaust normal limits.
|
|
82
|
+
|
|
83
|
+
## Scope
|
|
84
|
+
|
|
85
|
+
| Supported | Outside the 0.1 scope |
|
|
86
|
+
|---|---|
|
|
87
|
+
| RFC 7951 names, all built-in value encodings, containers/lists, key presence and duplicate key tuples | Parsing YANG/YIN; XML, NETCONF and RESTCONF transport APIs |
|
|
88
|
+
| Pre-resolved built-in types, leafrefs, union members, and augmented nodes supplied by the caller | Compiling YANG typedefs, uses, augment, choice, RPC, action or notification definitions |
|
|
89
|
+
| Exact decimal64, 64-bit integers, union member selection and identity namespace rules | Complete YANG semantic/datastore validation |
|
|
90
|
+
| RFC 7952 typed annotations on each node category | Interpreting annotation application semantics |
|
|
91
|
+
| Structural schema-less anydata validation and opaque anyxml | Inferring or resolving schemas inside opaque content |
|
|
92
|
+
|
|
93
|
+
**The schema format omits semantic constraints.** This gem cannot validate enumeration membership, identity derivation, allowed bit names/positions, derived range/length/pattern restrictions, mandatory/default/config properties, choice exclusivity, min/max-elements, unique statements, must/when expressions, leafref existence or instance-identifier existence. It does not add defaults or reorder system-ordered collections. Repeated leaf-list values are accepted because state leaf-lists may contain duplicates and the schema omits `config`. Instance-identifier syntax and namespace changes are checked, but node existence and predicate suitability are not resolved. See [schema format notes](docs/jtox-format.md).
|
|
94
|
+
|
|
95
|
+
## Development and conformance
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
bundle install
|
|
99
|
+
bundle exec rake test
|
|
100
|
+
COVERAGE=1 bundle exec rake test
|
|
101
|
+
bundle exec rbs -r bigdecimal -I sig validate
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The Ruby suite covers all 20 JSON/path examples in RFC 7951/7952, including the deliberately invalid union value and complete Appendix A. Test schemas are declared locally. RFC excerpts are fetched with pinned SHA-256 hashes into ignored `tmp/rfc/`, retaining source notices and extraction corrections. These documents and examples are test inputs, never dependencies or packaged files. No external implementation is downloaded or executed by the tests. See [conformance notes](docs/conformance.md).
|
|
105
|
+
|
|
106
|
+
`bundle exec ruby script/benchmark.rb` measures a bounded large record and limit rejection. CI tests maintained CRuby versions, head, JRuby and TruffleRuby on Linux/macOS, and both JSON major versions. Coverage must reach 90%.
|
|
107
|
+
|
|
108
|
+
## License and security
|
|
109
|
+
|
|
110
|
+
Released under the [MIT License](LICENSE.txt). Added runtime and development dependencies must offer MIT terms; Ruby, its standard libraries and their system dependencies are the explicit exception. See [implementation and dependency policy](docs/implementation.md). Report vulnerabilities through the private channel in [SECURITY.md](SECURITY.md).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Report vulnerabilities privately through [GitHub security advisories](https://github.com/ydah/yang_json/security/advisories/new).
|
|
4
|
+
Include the Ruby/json versions, a minimal schema and input, and the observed impact. Do not include credentials or private configuration data.
|
|
5
|
+
|
|
6
|
+
The 0.1.x series receives security fixes. This gem converts RFC 7951 data using a caller-supplied schema; it does not authenticate input, execute XPath, resolve remote modules, or implement complete YANG datastore validation. Use a full YANG validator when semantic constraints are required.
|
|
7
|
+
|
|
8
|
+
JSON member duplication, invalid UTF-8/noncharacters, nonfinite numbers, malformed metadata, and structure/type violations are rejected. Encoding always validates. Lenient decoding preserves unsupported values with warnings and must not be treated as accepted configuration. Review `warnings` before using such data.
|
|
9
|
+
|
|
10
|
+
Byte, nesting, node, collection and binary limits apply before or during conversion. Schema loading is also bounded. Raising these limits increases resource exposure. Pass trusted schemas and limits; protect application-level request sizes and concurrency separately. `anyxml` and schema-less `anydata` are opaque data, never executable XML or code.
|
|
11
|
+
|
|
12
|
+
Conformance documents and their notices are downloaded into ignored `tmp/` only. External implementations are not downloaded or executed by the tests. They are excluded from Git and the gem package. Dependencies retain their own licenses.
|
data/data/encoding.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Original encoding reference; normative rules are cited, not reproduced.
|
|
2
|
+
names: {rfc: '7951', section: '4', ruby: 'qualified String keys', json: 'minimal module qualification'}
|
|
3
|
+
container: {rfc: '7951', section: '5.2', ruby: Hash, json: object}
|
|
4
|
+
leaf-list: {rfc: '7951', section: '5.3', ruby: Array, json: array}
|
|
5
|
+
list: {rfc: '7951', section: '5.4', ruby: 'Array of Hash', json: 'array of objects'}
|
|
6
|
+
anydata: {rfc: '7951', section: '5.5', ruby: 'Hash with opaque content', json: object}
|
|
7
|
+
anyxml: {rfc: '7951', section: '5.6', ruby: 'JSON-compatible value', json: 'I-JSON value'}
|
|
8
|
+
int8_int16_int32_uint8_uint16_uint32: {rfc: '7951', section: '6.1', ruby: Integer, json: number}
|
|
9
|
+
int64_uint64: {rfc: '7951', section: '6.1', ruby: Integer, json: string}
|
|
10
|
+
decimal64: {rfc: '7951', section: '6.1', ruby: BigDecimal, json: string}
|
|
11
|
+
string: {rfc: '7951', section: '6.2', ruby: String, json: string}
|
|
12
|
+
boolean: {rfc: '7951', section: '6.3', ruby: boolean, json: boolean}
|
|
13
|
+
enumeration: {rfc: '7951', section: '6.4', ruby: String, json: string}
|
|
14
|
+
bits: {rfc: '7951', section: '6.5', ruby: 'Array of String', json: string}
|
|
15
|
+
binary: {rfc: '7951', section: '6.6', ruby: 'ASCII-8BIT String', json: 'base64 string'}
|
|
16
|
+
leafref: {rfc: '7951', section: '6.7', ruby: 'resolved referent type', json: 'resolved referent type'}
|
|
17
|
+
identityref: {rfc: '7951', section: '6.8', erratum: 7020, ruby: 'module-qualified String', json: string}
|
|
18
|
+
empty: {rfc: '7951', section: '6.9', ruby: 'true', json: '[null]'}
|
|
19
|
+
union: {rfc: '7951', section: '6.10', ruby: 'native member type', json: 'selected member type'}
|
|
20
|
+
instance-identifier: {rfc: '7951', section: '6.11', ruby: String, json: string}
|
|
21
|
+
metadata: {rfc: '7952', section: '5.2', ruby: 'typed annotations', json: 'annotation object or positional array'}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "bigdecimal"
|
|
6
|
+
|
|
7
|
+
module YangJSON
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class ParseError < Error; end
|
|
10
|
+
Violation = Data.define(:pointer, :message, :rule)
|
|
11
|
+
|
|
12
|
+
class ValidationError < Error
|
|
13
|
+
attr_reader :violations, :pointer
|
|
14
|
+
|
|
15
|
+
def initialize(violations)
|
|
16
|
+
@violations = violations.freeze
|
|
17
|
+
@pointer = violations.first&.pointer || ""
|
|
18
|
+
super(violations.first&.message || "Invalid YANG data")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class ConversionError < Error; end
|
|
23
|
+
|
|
24
|
+
class Document < Hash
|
|
25
|
+
attr_reader :warnings, :union_members
|
|
26
|
+
|
|
27
|
+
def initialize(values, warnings:, union_members: {})
|
|
28
|
+
super()
|
|
29
|
+
update(values)
|
|
30
|
+
@warnings = warnings.freeze
|
|
31
|
+
@union_members = union_members.freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module Core
|
|
36
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_.-]*\z/
|
|
37
|
+
NAME = /\A(?:[A-Za-z_][A-Za-z0-9_.-]*:)?[A-Za-z_][A-Za-z0-9_.-]*\z/
|
|
38
|
+
DEFAULTS = { max_bytes: 16 * 1024 * 1024, max_depth: 32, max_nodes: 100_000, max_items: 10_000, max_binary_bytes: 1024 * 1024 }.freeze
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
def limits(options)
|
|
43
|
+
unknown = options.keys - DEFAULTS.keys
|
|
44
|
+
raise ArgumentError, "Unknown limit option" unless unknown.empty?
|
|
45
|
+
|
|
46
|
+
result = DEFAULTS.merge(options)
|
|
47
|
+
raise ArgumentError, "Limits must be positive integers" unless result.values.all? { |value| value.is_a?(Integer) && value.positive? }
|
|
48
|
+
|
|
49
|
+
result.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pointer(parent, name)
|
|
53
|
+
"#{parent}/#{name.to_s.gsub("~", "~0").gsub("/", "~1")}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse(source, limits)
|
|
57
|
+
raise ParseError, "JSON source must be a string" unless source.is_a?(String)
|
|
58
|
+
raise ParseError, "Input byte limit exceeded" if source.bytesize > limits[:max_bytes]
|
|
59
|
+
|
|
60
|
+
source = source.dup.force_encoding(Encoding::UTF_8)
|
|
61
|
+
raise ParseError, "Invalid UTF-8" unless source.valid_encoding?
|
|
62
|
+
|
|
63
|
+
result = JSON.parse(source, allow_duplicate_key: false, max_nesting: limits[:max_depth], allow_nan: false)
|
|
64
|
+
copy(result, limits)
|
|
65
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
66
|
+
raise ParseError, "Invalid JSON", cause: nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def string(value)
|
|
70
|
+
return false unless value.is_a?(String) && value.valid_encoding? && (value.encoding == Encoding::UTF_8 || value.ascii_only?)
|
|
71
|
+
|
|
72
|
+
value.each_codepoint.none? { |point| (0xFDD0..0xFDEF).cover?(point) || (point & 0xFFFF) >= 0xFFFE }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def copy(value, limits = DEFAULTS, native: false, depth: 0, budget: [0, 0])
|
|
76
|
+
budget[0] += 1
|
|
77
|
+
raise ParseError, "Node count limit exceeded" if budget[0] > limits[:max_nodes]
|
|
78
|
+
raise ParseError, "Nesting limit exceeded" if depth > limits[:max_depth]
|
|
79
|
+
|
|
80
|
+
case value
|
|
81
|
+
when Hash
|
|
82
|
+
raise ParseError, "Object member limit exceeded" if value.length > limits[:max_items]
|
|
83
|
+
raise ParseError, "Invalid JSON member name" unless value.keys.all? { |name| string(name) }
|
|
84
|
+
|
|
85
|
+
budget[1] += value.keys.sum(&:bytesize)
|
|
86
|
+
raise ParseError, "Aggregate string byte limit exceeded" if budget[1] > limits[:max_bytes]
|
|
87
|
+
|
|
88
|
+
value.to_h { |name, item| [name.dup, copy(item, limits, native: native, depth: depth + 1, budget: budget)] }
|
|
89
|
+
when Array
|
|
90
|
+
raise ParseError, "Array item limit exceeded" if value.length > limits[:max_items]
|
|
91
|
+
|
|
92
|
+
value.map { |item| copy(item, limits, native: native, depth: depth + 1, budget: budget) }
|
|
93
|
+
when String
|
|
94
|
+
raise ParseError, "Invalid Unicode string" unless (native && value.encoding == Encoding::ASCII_8BIT) || string(value)
|
|
95
|
+
|
|
96
|
+
budget[1] += value.bytesize
|
|
97
|
+
raise ParseError, "String byte limit exceeded" if budget[1] > limits[:max_bytes]
|
|
98
|
+
|
|
99
|
+
value.dup
|
|
100
|
+
when Float
|
|
101
|
+
raise ParseError, "Non-finite number" unless value.finite?
|
|
102
|
+
|
|
103
|
+
value
|
|
104
|
+
when BigDecimal
|
|
105
|
+
raise ParseError, "Unexpected decimal object" unless native && value.finite?
|
|
106
|
+
|
|
107
|
+
value
|
|
108
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
109
|
+
else raise ParseError, "Unsupported Ruby value"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def freeze_tree(value)
|
|
114
|
+
case value
|
|
115
|
+
when Hash then value.each do |key, item|
|
|
116
|
+
key.freeze
|
|
117
|
+
freeze_tree(item)
|
|
118
|
+
end
|
|
119
|
+
when Array then value.each { |item| freeze_tree(item) }
|
|
120
|
+
end
|
|
121
|
+
value.freeze
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def precise(value)
|
|
125
|
+
case value
|
|
126
|
+
when Hash then value.transform_values { |item| precise(item) }
|
|
127
|
+
when Array then value.map { |item| precise(item) }
|
|
128
|
+
when Float
|
|
129
|
+
return value unless RUBY_ENGINE == "truffleruby"
|
|
130
|
+
|
|
131
|
+
number = format("%.17g", value)
|
|
132
|
+
number += ".0" unless number.match?(/[.eE]/)
|
|
133
|
+
JSON::Fragment.new(number)
|
|
134
|
+
else value
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YangJSON
|
|
4
|
+
class Node
|
|
5
|
+
attr_reader :name, :module_name, :kind, :type, :children, :keys
|
|
6
|
+
|
|
7
|
+
def initialize(name:, kind:, type: nil, children: {}, keys: [])
|
|
8
|
+
@name = name.freeze
|
|
9
|
+
@module_name = name.split(":", 2).first.freeze
|
|
10
|
+
@kind = kind.freeze
|
|
11
|
+
@type = Core.freeze_tree(type)
|
|
12
|
+
@children = children.freeze
|
|
13
|
+
@keys = Core.freeze_tree(keys)
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Schema
|
|
19
|
+
attr_reader :modules, :tree, :annotations
|
|
20
|
+
|
|
21
|
+
# @param source [String] a schema in the documented JSON format
|
|
22
|
+
# @return [Schema] immutable schema
|
|
23
|
+
def self.from_json(source, **options)
|
|
24
|
+
limits = Core.limits(options)
|
|
25
|
+
new(Core.parse(source, limits), limits: limits)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
alias from_jtox from_json
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(data, limits: Core::DEFAULTS)
|
|
33
|
+
raise ParseError, "jtox schema must contain modules, tree, and annotations" unless data.is_a?(Hash) && %w[modules tree annotations].all? do |key|
|
|
34
|
+
data[key].is_a?(Hash)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
data = Core.copy(data, limits)
|
|
38
|
+
@modules = data.fetch("modules")
|
|
39
|
+
@modules.each do |name, definition|
|
|
40
|
+
unless Core::IDENTIFIER.match?(name) && definition.is_a?(Array) && definition.length == 2 && definition.all?(String) && Core::IDENTIFIER.match?(definition[0])
|
|
41
|
+
raise ParseError, "Invalid jtox module definition"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
@modules = Core.freeze_tree(@modules)
|
|
45
|
+
@tree = build_nodes(data.fetch("tree"), nil)
|
|
46
|
+
@annotations = data.fetch("annotations").to_h do |name, type|
|
|
47
|
+
qualified(name, nil)
|
|
48
|
+
check_type(type)
|
|
49
|
+
[name, Core.freeze_tree(type)]
|
|
50
|
+
end.freeze
|
|
51
|
+
freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [String, Array<String>] qualified root and simple/qualified descendants
|
|
55
|
+
# @return [Node, nil] schema node, or nil when absent
|
|
56
|
+
def lookup(path)
|
|
57
|
+
parts = path.is_a?(String) ? path.delete_prefix("/").split("/") : path
|
|
58
|
+
return nil unless parts.is_a?(Array)
|
|
59
|
+
|
|
60
|
+
nodes = tree
|
|
61
|
+
parent = nil
|
|
62
|
+
node = nil
|
|
63
|
+
parts.each do |part|
|
|
64
|
+
name = part.include?(":") ? part : "#{parent}:#{part}"
|
|
65
|
+
node = nodes[name]
|
|
66
|
+
return nil unless node
|
|
67
|
+
|
|
68
|
+
parent = node.module_name
|
|
69
|
+
nodes = node.children
|
|
70
|
+
end
|
|
71
|
+
node
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def qualified(name, parent)
|
|
77
|
+
raise ParseError, "Invalid jtox node name" unless name.is_a?(String) && Core::NAME.match?(name)
|
|
78
|
+
|
|
79
|
+
full = name.include?(":") ? name : "#{parent}:#{name}"
|
|
80
|
+
raise ParseError, "Unknown or absent jtox module" unless modules.key?(full.split(":", 2).first)
|
|
81
|
+
|
|
82
|
+
full
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_type(type)
|
|
86
|
+
if type.is_a?(String)
|
|
87
|
+
raise ParseError, "Unsupported or unresolved jtox type" unless Types::PRIMITIVES.include?(type)
|
|
88
|
+
elsif type.is_a?(Array) && type.length == 2
|
|
89
|
+
if type[0] == "decimal64"
|
|
90
|
+
raise ParseError, "Invalid fraction-digits" unless type[1].is_a?(Integer) && (1..18).cover?(type[1])
|
|
91
|
+
elsif type[0] == "union" && type[1].is_a?(Array) && !type[1].empty?
|
|
92
|
+
type[1].each { |member| check_type(member) }
|
|
93
|
+
else
|
|
94
|
+
raise ParseError, "Invalid jtox compound type"
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
raise ParseError, "Invalid jtox type"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_nodes(tree, parent)
|
|
102
|
+
raise ParseError, "Invalid jtox tree" unless tree.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
tree.each_with_object({}) do |(name, definition), output|
|
|
105
|
+
full = qualified(name, parent)
|
|
106
|
+
raise ParseError, "Duplicate normalized jtox node" if output.key?(full)
|
|
107
|
+
raise ParseError, "Invalid jtox node" unless definition.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
kind = definition[0]
|
|
110
|
+
case kind
|
|
111
|
+
when "container", "list"
|
|
112
|
+
expected = kind == "list" ? 3 : 2
|
|
113
|
+
raise ParseError, "Invalid jtox branch" unless definition.length == expected
|
|
114
|
+
|
|
115
|
+
children = build_nodes(definition[1], full.split(":", 2).first)
|
|
116
|
+
keys = kind == "list" ? definition[2] : []
|
|
117
|
+
raise ParseError, "Invalid jtox list keys" unless keys.is_a?(Array) && keys.all? { |key| key.is_a?(Array) && key.length == 2 && key.all?(String) }
|
|
118
|
+
|
|
119
|
+
keys = keys.map { |key| key.join(":") }
|
|
120
|
+
raise ParseError, "List key must reference a child leaf" unless keys.uniq == keys && keys.all? { |key| children[key]&.kind == "leaf" }
|
|
121
|
+
|
|
122
|
+
output[full] = Node.new(name: full, kind: kind, children: children, keys: keys)
|
|
123
|
+
when "leaf", "leaf-list"
|
|
124
|
+
raise ParseError, "Invalid jtox leaf" unless definition.length == 2
|
|
125
|
+
|
|
126
|
+
check_type(definition[1])
|
|
127
|
+
output[full] = Node.new(name: full, kind: kind, type: definition[1])
|
|
128
|
+
when "anydata", "anyxml"
|
|
129
|
+
raise ParseError, "Invalid jtox opaque node" unless definition.length == 1
|
|
130
|
+
|
|
131
|
+
output[full] = Node.new(name: full, kind: kind)
|
|
132
|
+
else raise ParseError, "Unsupported jtox node kind"
|
|
133
|
+
end
|
|
134
|
+
end.freeze
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YangJSON
|
|
4
|
+
class Transformer
|
|
5
|
+
attr_reader :violations, :selections
|
|
6
|
+
|
|
7
|
+
def initialize(schema, mode:, limits:, selections: {})
|
|
8
|
+
raise ArgumentError, "Expected a YangJSON::Schema" unless schema.is_a?(Schema)
|
|
9
|
+
|
|
10
|
+
@schema = schema
|
|
11
|
+
@mode = mode
|
|
12
|
+
@limits = limits
|
|
13
|
+
@violations = []
|
|
14
|
+
@selections = selections.dup
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def transform(value)
|
|
18
|
+
object(value, @schema.tree, nil, "", "", metadata_allowed: false)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def invalid(pointer, message, rule)
|
|
24
|
+
@violations << Violation.new(pointer: pointer.freeze, message: message.freeze, rule: rule.freeze)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def object(value, nodes, parent, pointer, location, metadata_allowed: true)
|
|
28
|
+
unless value.is_a?(Hash)
|
|
29
|
+
invalid(pointer, "Expected an object", "structure")
|
|
30
|
+
return value
|
|
31
|
+
end
|
|
32
|
+
output = {}
|
|
33
|
+
normalized = {}
|
|
34
|
+
value.each do |name, item|
|
|
35
|
+
next if name.start_with?("@")
|
|
36
|
+
|
|
37
|
+
path = Core.pointer(pointer, name)
|
|
38
|
+
unless Core::NAME.match?(name)
|
|
39
|
+
invalid(path, "Invalid YANG member name", "name")
|
|
40
|
+
output[name] = item
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
full = if name.include?(":")
|
|
44
|
+
name
|
|
45
|
+
else
|
|
46
|
+
(parent ? "#{parent}:#{name}" : name)
|
|
47
|
+
end
|
|
48
|
+
node = nodes[full]
|
|
49
|
+
unless node
|
|
50
|
+
invalid(path, "Unknown schema node", "schema")
|
|
51
|
+
output[full] = item
|
|
52
|
+
normalized[name] = [full, nil]
|
|
53
|
+
next
|
|
54
|
+
end
|
|
55
|
+
expected = @mode == :encode ? full : wire_name(node, parent)
|
|
56
|
+
invalid(path, "Invalid module qualification", "namespace") unless name == expected
|
|
57
|
+
target = @mode == :decode ? full : wire_name(node, parent)
|
|
58
|
+
if output.key?(target)
|
|
59
|
+
invalid(path, "Duplicate normalized member", "duplicate")
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
normalized[name] = [target, node]
|
|
63
|
+
output[target] = node_value(item, node, path, Core.pointer(location, full))
|
|
64
|
+
end
|
|
65
|
+
# Resolve data members first: metadata is allowed to precede its target.
|
|
66
|
+
value.each do |name, item| # rubocop:disable Style/CombinableLoops
|
|
67
|
+
next unless name.start_with?("@")
|
|
68
|
+
|
|
69
|
+
path = Core.pointer(pointer, name)
|
|
70
|
+
if name == "@"
|
|
71
|
+
invalid(path, "Metadata has no enclosing data node", "metadata") unless metadata_allowed
|
|
72
|
+
output[name] = annotations(item, path, Core.pointer(location, "@"))
|
|
73
|
+
next
|
|
74
|
+
end
|
|
75
|
+
target, node = normalized[name.delete_prefix("@")]
|
|
76
|
+
unless node
|
|
77
|
+
invalid(path, "Metadata must accompany an existing schema node", "metadata")
|
|
78
|
+
output[name] = item
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
if node.kind == "leaf-list"
|
|
82
|
+
if !item.is_a?(Array) || !value[name.delete_prefix("@")].is_a?(Array) || item.length > value[name.delete_prefix("@")].length
|
|
83
|
+
invalid(path, "Leaf-list metadata must align with its values", "metadata")
|
|
84
|
+
output["@#{target}"] = item
|
|
85
|
+
else
|
|
86
|
+
output["@#{target}"] = item.each_with_index.map do |entry, index|
|
|
87
|
+
entry.nil? ? nil : annotations(entry, Core.pointer(path, index), Core.pointer(location, "@#{node.name}/#{index}"))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
elsif %w[leaf anyxml].include?(node.kind)
|
|
91
|
+
output["@#{target}"] = annotations(item, path, Core.pointer(location, "@#{node.name}"))
|
|
92
|
+
else
|
|
93
|
+
invalid(path, "Object node metadata belongs inside its @ member", "metadata")
|
|
94
|
+
output["@#{target}"] = item
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
output
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def node_value(value, node, pointer, location)
|
|
101
|
+
case node.kind
|
|
102
|
+
when "container" then object(value, node.children, node.module_name, pointer, location)
|
|
103
|
+
when "list"
|
|
104
|
+
unless value.is_a?(Array)
|
|
105
|
+
invalid(pointer, "List must be an array", "structure")
|
|
106
|
+
return value
|
|
107
|
+
end
|
|
108
|
+
entries = value.each_with_index.map do |entry, index|
|
|
109
|
+
object(entry, node.children, node.module_name, Core.pointer(pointer, index), Core.pointer(location, index))
|
|
110
|
+
end
|
|
111
|
+
seen = {}
|
|
112
|
+
entries.each_with_index do |entry, index|
|
|
113
|
+
next unless entry.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
keys = node.keys.map { |name| @mode == :decode ? name : wire_name(node.children.fetch(name), node.module_name) }
|
|
116
|
+
missing = keys.reject { |key| entry.key?(key) }
|
|
117
|
+
missing.each do |key|
|
|
118
|
+
child = node.children.values.find { |candidate| candidate.name == key || wire_name(candidate, node.module_name) == key }
|
|
119
|
+
input_key = @mode == :decode ? wire_name(child, node.module_name) : child.name
|
|
120
|
+
invalid(Core.pointer(Core.pointer(pointer, index), input_key), "Required list key is missing", "key")
|
|
121
|
+
end
|
|
122
|
+
next unless missing.empty? && !keys.empty?
|
|
123
|
+
|
|
124
|
+
tuple = keys.map { |key| entry[key] }
|
|
125
|
+
invalid(Core.pointer(pointer, index), "Duplicate list key tuple", "unique") if seen[tuple]
|
|
126
|
+
seen[tuple] = true
|
|
127
|
+
end
|
|
128
|
+
entries
|
|
129
|
+
when "leaf" then typed(value, node.type, node.module_name, pointer, location)
|
|
130
|
+
when "leaf-list"
|
|
131
|
+
unless value.is_a?(Array)
|
|
132
|
+
invalid(pointer, "Leaf-list must be an array", "structure")
|
|
133
|
+
return value
|
|
134
|
+
end
|
|
135
|
+
# jtox omits config; YANG 1.1 permits repeated values for state leaf-lists.
|
|
136
|
+
value.each_with_index.map do |entry, index|
|
|
137
|
+
typed(entry, node.type, node.module_name, Core.pointer(pointer, index), Core.pointer(location, index))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
when "anydata"
|
|
141
|
+
unless value.is_a?(Hash)
|
|
142
|
+
invalid(pointer, "anydata must be an object", "structure")
|
|
143
|
+
return value
|
|
144
|
+
end
|
|
145
|
+
anydata_object(value, pointer, location)
|
|
146
|
+
when "anyxml"
|
|
147
|
+
opaque(value, pointer, anydata: false)
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def typed(value, type, module_name, pointer, location)
|
|
153
|
+
Types.convert(value, type, mode: @mode, module_name: module_name, schema: @schema, limits: @limits,
|
|
154
|
+
pointer: location, selections: @selections)
|
|
155
|
+
rescue ConversionError => e
|
|
156
|
+
invalid(pointer, e.message, "type")
|
|
157
|
+
value
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def annotations(value, pointer, location)
|
|
161
|
+
unless value.is_a?(Hash)
|
|
162
|
+
invalid(pointer, "Metadata object must be an object", "metadata")
|
|
163
|
+
return value
|
|
164
|
+
end
|
|
165
|
+
value.to_h do |name, item|
|
|
166
|
+
path = Core.pointer(pointer, name)
|
|
167
|
+
type = @schema.annotations[name]
|
|
168
|
+
if !Core::NAME.match?(name) || !name.include?(":") || !type
|
|
169
|
+
invalid(path, "Unknown or unqualified metadata annotation", "metadata")
|
|
170
|
+
[name, item]
|
|
171
|
+
else
|
|
172
|
+
[name, typed(item, type, name.split(":", 2).first, path, Core.pointer(location, name))]
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def wire_name(node, parent)
|
|
178
|
+
node.module_name == parent ? node.name.split(":", 2).last : node.name
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Names and values inside schema-less anydata remain in wire form. Only
|
|
182
|
+
# registered annotations can be assigned native types without a content schema.
|
|
183
|
+
def anydata_object(value, pointer, location)
|
|
184
|
+
value.to_h do |name, item|
|
|
185
|
+
path = Core.pointer(pointer, name)
|
|
186
|
+
position = Core.pointer(location, name)
|
|
187
|
+
if name == "@"
|
|
188
|
+
[name, annotations(item, path, position)]
|
|
189
|
+
elsif name.start_with?("@")
|
|
190
|
+
sibling = value[name.delete_prefix("@")]
|
|
191
|
+
invalid(path, "Metadata must accompany a scalar or leaf-list", "metadata") unless value.key?(name.delete_prefix("@")) && !sibling.is_a?(Hash)
|
|
192
|
+
if sibling.is_a?(Array) && sibling != [nil]
|
|
193
|
+
if item.is_a?(Array) && item.length <= sibling.length && sibling.none?(Hash)
|
|
194
|
+
[name, item.each_with_index.map { |entry, i| entry.nil? ? nil : annotations(entry, Core.pointer(path, i), Core.pointer(position, i)) }]
|
|
195
|
+
else
|
|
196
|
+
invalid(path, "Leaf-list metadata must align with its values", "metadata")
|
|
197
|
+
[name, item]
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
[name, annotations(item, path, position)]
|
|
201
|
+
end
|
|
202
|
+
else
|
|
203
|
+
invalid(path, "Invalid anydata member name", "anydata") unless Core::NAME.match?(name)
|
|
204
|
+
if item.is_a?(Hash)
|
|
205
|
+
[name, anydata_object(item, path, position)]
|
|
206
|
+
elsif item.is_a?(Array) && item.all?(Hash)
|
|
207
|
+
[name, item.each_with_index.map { |entry, i| anydata_object(entry, Core.pointer(path, i), Core.pointer(position, i)) }]
|
|
208
|
+
else
|
|
209
|
+
opaque(item, path, anydata: true)
|
|
210
|
+
[name, item]
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def opaque(value, pointer, anydata:)
|
|
217
|
+
case value
|
|
218
|
+
when Hash
|
|
219
|
+
value.each do |name, item|
|
|
220
|
+
invalid(Core.pointer(pointer, name), "Invalid anydata member name", "anydata") if anydata && !Core::NAME.match?(name)
|
|
221
|
+
opaque(item, Core.pointer(pointer, name), anydata: anydata)
|
|
222
|
+
end
|
|
223
|
+
when Array
|
|
224
|
+
if anydata
|
|
225
|
+
return if value == [nil]
|
|
226
|
+
|
|
227
|
+
objects = value.all?(Hash)
|
|
228
|
+
scalars = value.all? { |entry| entry == [nil] || !(entry.is_a?(Hash) || entry.is_a?(Array) || entry.nil?) }
|
|
229
|
+
unless objects || (scalars && value.uniq.length == value.length)
|
|
230
|
+
invalid(pointer, "anydata array must contain objects or unique scalar values", "anydata")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
value.each_with_index { |item, index| opaque(item, Core.pointer(pointer, index), anydata: anydata) }
|
|
234
|
+
when NilClass
|
|
235
|
+
invalid(pointer, "anydata null is only valid as [null]", "anydata") if anydata
|
|
236
|
+
when Integer
|
|
237
|
+
invalid(pointer, "Opaque JSON integer exceeds the interoperable range", "i-json") if value.abs > ((2**53) - 1)
|
|
238
|
+
when BigDecimal
|
|
239
|
+
invalid(pointer, "Opaque JSON cannot contain native decimal values", "i-json")
|
|
240
|
+
when String
|
|
241
|
+
invalid(pointer, "Opaque JSON requires Unicode strings", "i-json") unless Core.string(value)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module YangJSON
|
|
4
|
+
module Types
|
|
5
|
+
PRIMITIVES = %w[int8 int16 int32 int64 uint8 uint16 uint32 uint64 string enumeration boolean bits binary identityref empty
|
|
6
|
+
instance-identifier].each(&:freeze).freeze
|
|
7
|
+
INTEGER_TYPES = %w[int8 int16 int32 int64 uint8 uint16 uint32 uint64].each(&:freeze).freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def convert(value, type, mode:, module_name:, schema:, limits:, pointer:, selections:)
|
|
12
|
+
name, parameter = type.is_a?(Array) ? type : [type, nil]
|
|
13
|
+
if name == "union"
|
|
14
|
+
indices = (0...parameter.length).to_a
|
|
15
|
+
preferred = selections[pointer] if mode == :encode
|
|
16
|
+
indices = [preferred] + (indices - [preferred]) if preferred && indices.include?(preferred)
|
|
17
|
+
indices.each do |index|
|
|
18
|
+
result = convert(value, parameter[index], mode: mode, module_name: module_name, schema: schema, limits: limits,
|
|
19
|
+
pointer: "#{pointer}/union#{index}", selections: selections)
|
|
20
|
+
selections[pointer] = index if mode == :decode
|
|
21
|
+
return result
|
|
22
|
+
rescue ConversionError
|
|
23
|
+
next
|
|
24
|
+
end
|
|
25
|
+
raise ConversionError, "Value does not match any union member"
|
|
26
|
+
end
|
|
27
|
+
case name
|
|
28
|
+
when *INTEGER_TYPES then integer(value, name, mode)
|
|
29
|
+
when "decimal64" then decimal(value, parameter, mode)
|
|
30
|
+
when "string", "enumeration"
|
|
31
|
+
raise ConversionError, "Expected a Unicode string" unless Core.string(value)
|
|
32
|
+
|
|
33
|
+
value.dup
|
|
34
|
+
when "boolean"
|
|
35
|
+
raise ConversionError, "Expected a boolean" unless [true, false].include?(value)
|
|
36
|
+
|
|
37
|
+
value
|
|
38
|
+
when "empty"
|
|
39
|
+
expected = mode == :decode ? [nil] : true
|
|
40
|
+
raise ConversionError, "Invalid empty value" unless value == expected
|
|
41
|
+
|
|
42
|
+
mode == :decode ? true : [nil]
|
|
43
|
+
when "bits" then bits(value, mode)
|
|
44
|
+
when "binary" then binary(value, mode, limits[:max_binary_bytes])
|
|
45
|
+
when "identityref" then identity(value, mode, module_name, schema)
|
|
46
|
+
when "instance-identifier"
|
|
47
|
+
instance_identifier(value, schema)
|
|
48
|
+
value.dup
|
|
49
|
+
else raise ConversionError, "Unsupported data type"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def integer(value, type, mode)
|
|
54
|
+
bits = type.delete_prefix("u").delete_prefix("int").to_i
|
|
55
|
+
if mode == :decode && bits == 64
|
|
56
|
+
raise ConversionError, "64-bit integer must be a decimal string" unless value.is_a?(String) && value.match?(/\A[+-]?[0-9]+\z/)
|
|
57
|
+
raise ConversionError, "Integer representation is too long" if value.length > 128
|
|
58
|
+
|
|
59
|
+
number = Integer(value, 10)
|
|
60
|
+
else
|
|
61
|
+
raise ConversionError, "Expected an integer" unless value.is_a?(Integer)
|
|
62
|
+
|
|
63
|
+
number = value
|
|
64
|
+
end
|
|
65
|
+
range = type.start_with?("u") ? (0..((2**bits) - 1)) : (-(2**(bits - 1))..((2**(bits - 1)) - 1))
|
|
66
|
+
raise ConversionError, "Integer is out of range" unless range.cover?(number)
|
|
67
|
+
|
|
68
|
+
mode == :encode && bits == 64 ? number.to_s : number
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decimal(value, digits, mode)
|
|
72
|
+
if mode == :decode
|
|
73
|
+
unless value.is_a?(String) && value.length <= 128 && value.match?(/\A[+-]?[0-9]+(?:\.[0-9]+)?\z/)
|
|
74
|
+
raise ConversionError, "decimal64 must be a decimal string"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
number = BigDecimal(value)
|
|
78
|
+
else
|
|
79
|
+
raise ConversionError, "decimal64 requires BigDecimal" unless value.is_a?(BigDecimal) && value.finite?
|
|
80
|
+
|
|
81
|
+
number = value
|
|
82
|
+
end
|
|
83
|
+
scaled = number * (10**digits)
|
|
84
|
+
raise ConversionError, "decimal64 precision or range exceeded" unless scaled.frac.zero? && scaled >= -(2**63) && scaled <= (2**63) - 1
|
|
85
|
+
|
|
86
|
+
if mode == :decode
|
|
87
|
+
number
|
|
88
|
+
else
|
|
89
|
+
(number.zero? ? "0.0" : number.to_s("F"))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def bits(value, mode)
|
|
94
|
+
if mode == :decode
|
|
95
|
+
raise ConversionError, "bits requires a string" unless value.is_a?(String)
|
|
96
|
+
|
|
97
|
+
names = value.split(/[ \t\r\n]+/).reject(&:empty?)
|
|
98
|
+
else
|
|
99
|
+
raise ConversionError, "bits requires an array" unless value.is_a?(Array)
|
|
100
|
+
|
|
101
|
+
names = value
|
|
102
|
+
end
|
|
103
|
+
unless names.all? { |name| name.is_a?(String) && Core::IDENTIFIER.match?(name) } && names.uniq == names
|
|
104
|
+
raise ConversionError, "Invalid or duplicate bit name"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
mode == :decode ? names : names.join(" ")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def binary(value, mode, limit)
|
|
111
|
+
raise ConversionError, "binary requires a string" unless value.is_a?(String)
|
|
112
|
+
|
|
113
|
+
if mode == :decode
|
|
114
|
+
raise ConversionError, "Decoded binary limit exceeded" if value.bytesize > ((limit + 2) / 3) * 4
|
|
115
|
+
|
|
116
|
+
bytes = Base64.strict_decode64(value)
|
|
117
|
+
raise ConversionError, "Noncanonical base64 value" unless Base64.strict_encode64(bytes) == value
|
|
118
|
+
else
|
|
119
|
+
raise ConversionError, "binary requires an ASCII-8BIT string" unless value.encoding == Encoding::ASCII_8BIT
|
|
120
|
+
|
|
121
|
+
bytes = value
|
|
122
|
+
end
|
|
123
|
+
raise ConversionError, "Binary byte limit exceeded" if bytes.bytesize > limit
|
|
124
|
+
|
|
125
|
+
mode == :decode ? bytes : Base64.strict_encode64(bytes)
|
|
126
|
+
rescue ArgumentError
|
|
127
|
+
raise ConversionError, "Invalid base64 value", cause: nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def identity(value, mode, parent, schema)
|
|
131
|
+
raise ConversionError, "Invalid identity name" unless value.is_a?(String) && Core::NAME.match?(value)
|
|
132
|
+
raise ConversionError, "Ruby identity must be module-qualified" if mode == :encode && !value.include?(":")
|
|
133
|
+
|
|
134
|
+
full = value.include?(":") ? value : "#{parent}:#{value}"
|
|
135
|
+
module_name, name = full.split(":", 2)
|
|
136
|
+
raise ConversionError, "Unknown identity module" unless schema.modules.key?(module_name)
|
|
137
|
+
|
|
138
|
+
if mode == :decode
|
|
139
|
+
full
|
|
140
|
+
else
|
|
141
|
+
(module_name == parent ? name : full)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def instance_identifier(value, schema)
|
|
146
|
+
raise ConversionError, "instance-identifier must be an absolute path" unless Core.string(value) && value.start_with?("/")
|
|
147
|
+
|
|
148
|
+
offset = 0
|
|
149
|
+
parent = nil
|
|
150
|
+
while offset < value.length
|
|
151
|
+
match = %r{\G/((?:[A-Za-z_][A-Za-z0-9_.-]*:)?[A-Za-z_][A-Za-z0-9_.-]*)}.match(value, offset)
|
|
152
|
+
raise ConversionError, "Invalid instance-identifier step" unless match
|
|
153
|
+
|
|
154
|
+
name = match[1]
|
|
155
|
+
module_name = name.include?(":") ? name.split(":", 2).first : parent
|
|
156
|
+
unless module_name && schema.modules.key?(module_name) && (name.include?(":") == (module_name != parent))
|
|
157
|
+
raise ConversionError, "Invalid instance-identifier namespace"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
parent = module_name
|
|
161
|
+
offset = match.end(0)
|
|
162
|
+
predicate_kind = nil
|
|
163
|
+
while value[offset] == "["
|
|
164
|
+
predicate = /\G\[[ \t]*(?:(?:((?:[A-Za-z_][A-Za-z0-9_.-]*:)?[A-Za-z_][A-Za-z0-9_.-]*|\.)[ \t]*=[ \t]*(?:'[^']*'|"[^"]*"))|[1-9][0-9]*)[ \t]*\]/.match(
|
|
165
|
+
value, offset
|
|
166
|
+
)
|
|
167
|
+
raise ConversionError, "Invalid instance-identifier predicate" unless predicate
|
|
168
|
+
|
|
169
|
+
key = predicate[1]
|
|
170
|
+
kind = key && key != "." ? :key : :single
|
|
171
|
+
raise ConversionError, "Incompatible instance-identifier predicates" if predicate_kind && (predicate_kind != :key || kind != :key)
|
|
172
|
+
|
|
173
|
+
predicate_kind = kind
|
|
174
|
+
if key && key != "." && key.include?(":")
|
|
175
|
+
module_name = key.split(":", 2).first
|
|
176
|
+
raise ConversionError, "Invalid predicate namespace" if module_name == parent || !schema.modules.key?(module_name)
|
|
177
|
+
end
|
|
178
|
+
offset = predicate.end(0)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
data/lib/yang_json.rb
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "yang_json/version"
|
|
4
|
+
require_relative "yang_json/core"
|
|
5
|
+
require_relative "yang_json/types"
|
|
6
|
+
require_relative "yang_json/schema"
|
|
7
|
+
require_relative "yang_json/transformer"
|
|
8
|
+
|
|
9
|
+
module YangJSON
|
|
10
|
+
# Decode RFC 7951 JSON to module-qualified Ruby values.
|
|
11
|
+
# @param source [String] UTF-8 JSON text
|
|
12
|
+
# @param schema [Schema] immutable caller-defined schema
|
|
13
|
+
# @param strict [Boolean] whether semantic violations raise an exception
|
|
14
|
+
# @return [Document] converted data, diagnostics and union selections
|
|
15
|
+
# @raise [ParseError] malformed JSON or a resource limit violation
|
|
16
|
+
# @raise [ValidationError] a type or structure violation in strict mode
|
|
17
|
+
def self.decode(source, schema, strict: true, **options)
|
|
18
|
+
limits = Core.limits(options)
|
|
19
|
+
value = Core.parse(source, limits)
|
|
20
|
+
transformer = Transformer.new(schema, mode: :decode, limits: limits)
|
|
21
|
+
result = transformer.transform(value)
|
|
22
|
+
raise ValidationError, transformer.violations if strict && !transformer.violations.empty?
|
|
23
|
+
raise ValidationError, transformer.violations unless result.is_a?(Hash)
|
|
24
|
+
|
|
25
|
+
Document.new(result, warnings: transformer.violations, union_members: transformer.selections)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Encode native values; all validation failures are errors.
|
|
29
|
+
# @param data [Hash] native values with module-qualified modeled keys
|
|
30
|
+
# @param schema [Schema] immutable caller-defined schema
|
|
31
|
+
# @return [String] RFC 7951 JSON
|
|
32
|
+
# @raise [ValidationError] data cannot be represented by the schema
|
|
33
|
+
def self.encode(data, schema, **options)
|
|
34
|
+
limits = Core.limits(options)
|
|
35
|
+
value = Core.copy(data, limits, native: true)
|
|
36
|
+
selections = data.is_a?(Document) ? data.union_members : {}
|
|
37
|
+
transformer = Transformer.new(schema, mode: :encode, limits: limits, selections: selections)
|
|
38
|
+
result = transformer.transform(value)
|
|
39
|
+
raise ValidationError, transformer.violations unless transformer.violations.empty?
|
|
40
|
+
|
|
41
|
+
result = Core.copy(result, limits)
|
|
42
|
+
encoded = JSON.generate(Core.precise(result), allow_nan: false, max_nesting: limits[:max_depth])
|
|
43
|
+
raise ConversionError, "Output byte limit exceeded" if encoded.bytesize > limits[:max_bytes]
|
|
44
|
+
|
|
45
|
+
encoded
|
|
46
|
+
rescue JSON::GeneratorError
|
|
47
|
+
raise ConversionError, "Invalid JSON output", cause: nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Array<Violation>] all diagnostics, including parse failures
|
|
51
|
+
def self.validate(source, schema, **)
|
|
52
|
+
decode(source, schema, strict: false, **).warnings
|
|
53
|
+
rescue ParseError => e
|
|
54
|
+
[Violation.new(pointer: "", message: e.message, rule: "parse")]
|
|
55
|
+
rescue ValidationError => e
|
|
56
|
+
e.violations
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.parse(source, schema:, **)
|
|
60
|
+
decode(source, schema, **)
|
|
61
|
+
end
|
|
62
|
+
end
|
data/sig/yang_json.rbs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module YangJSON
|
|
2
|
+
VERSION: String
|
|
3
|
+
type json = nil | bool | Integer | Float | String | Array[json] | Hash[String, json]
|
|
4
|
+
type value = json | BigDecimal | Array[value] | Hash[String, value]
|
|
5
|
+
type limits = { ?max_bytes: Integer, ?max_depth: Integer, ?max_nodes: Integer, ?max_items: Integer, ?max_binary_bytes: Integer }
|
|
6
|
+
type yang_type = String | Array[untyped]
|
|
7
|
+
|
|
8
|
+
class Error < StandardError
|
|
9
|
+
end
|
|
10
|
+
class ParseError < Error
|
|
11
|
+
end
|
|
12
|
+
class ConversionError < Error
|
|
13
|
+
end
|
|
14
|
+
class Violation < Data
|
|
15
|
+
attr_reader pointer: String
|
|
16
|
+
attr_reader message: String
|
|
17
|
+
attr_reader rule: String
|
|
18
|
+
def self.new: (pointer: String, message: String, rule: String) -> instance
|
|
19
|
+
end
|
|
20
|
+
class ValidationError < Error
|
|
21
|
+
attr_reader violations: Array[Violation]
|
|
22
|
+
attr_reader pointer: String
|
|
23
|
+
def initialize: (Array[Violation]) -> void
|
|
24
|
+
end
|
|
25
|
+
class Document < Hash[String, value]
|
|
26
|
+
attr_reader warnings: Array[Violation]
|
|
27
|
+
attr_reader union_members: Hash[String, Integer]
|
|
28
|
+
def initialize: (Hash[String, value], warnings: Array[Violation], ?union_members: Hash[String, Integer]) -> void
|
|
29
|
+
end
|
|
30
|
+
class Node
|
|
31
|
+
attr_reader name: String
|
|
32
|
+
attr_reader module_name: String
|
|
33
|
+
attr_reader kind: String
|
|
34
|
+
attr_reader type: yang_type?
|
|
35
|
+
attr_reader children: Hash[String, Node]
|
|
36
|
+
attr_reader keys: Array[String]
|
|
37
|
+
def initialize: (name: String, kind: String, ?type: yang_type?, ?children: Hash[String, Node], ?keys: Array[String]) -> void
|
|
38
|
+
end
|
|
39
|
+
class Schema
|
|
40
|
+
attr_reader modules: Hash[String, Array[String]]
|
|
41
|
+
attr_reader tree: Hash[String, Node]
|
|
42
|
+
attr_reader annotations: Hash[String, yang_type]
|
|
43
|
+
def self.from_json: (String, **Integer) -> instance
|
|
44
|
+
def self.from_jtox: (String, **Integer) -> instance
|
|
45
|
+
def initialize: (Hash[String, untyped], ?limits: limits) -> void
|
|
46
|
+
def lookup: (String | Array[String]) -> Node?
|
|
47
|
+
end
|
|
48
|
+
def self.decode: (String, Schema, ?strict: bool, **Integer) -> Document
|
|
49
|
+
def self.parse: (String, schema: Schema, ?strict: bool, **Integer) -> Document
|
|
50
|
+
def self.encode: (Hash[String, value], Schema, **Integer) -> String
|
|
51
|
+
def self.validate: (String, Schema, **Integer) -> Array[Violation]
|
|
52
|
+
end
|
|
53
|
+
module YangJson = YangJSON
|
metadata
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: yang_json
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '1'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '0.2'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '1'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: bigdecimal
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.1'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '5'
|
|
42
|
+
type: :runtime
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.1'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '5'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: json
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 2.19.4
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '4'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 2.19.4
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '4'
|
|
72
|
+
description: Schema-aware YANG JSON value conversion, namespace normalization, structural
|
|
73
|
+
validation, and RFC 7952 annotations.
|
|
74
|
+
email:
|
|
75
|
+
- t.yudai92@gmail.com
|
|
76
|
+
executables: []
|
|
77
|
+
extensions: []
|
|
78
|
+
extra_rdoc_files: []
|
|
79
|
+
files:
|
|
80
|
+
- CHANGELOG.md
|
|
81
|
+
- LICENSE.txt
|
|
82
|
+
- README.md
|
|
83
|
+
- SECURITY.md
|
|
84
|
+
- data/encoding.yml
|
|
85
|
+
- lib/yang_json.rb
|
|
86
|
+
- lib/yang_json/core.rb
|
|
87
|
+
- lib/yang_json/schema.rb
|
|
88
|
+
- lib/yang_json/transformer.rb
|
|
89
|
+
- lib/yang_json/types.rb
|
|
90
|
+
- lib/yang_json/version.rb
|
|
91
|
+
- sig/yang_json.rbs
|
|
92
|
+
homepage: https://github.com/ydah/yang_json
|
|
93
|
+
licenses:
|
|
94
|
+
- MIT
|
|
95
|
+
metadata:
|
|
96
|
+
allowed_push_host: https://rubygems.org
|
|
97
|
+
homepage_uri: https://github.com/ydah/yang_json
|
|
98
|
+
source_code_uri: https://github.com/ydah/yang_json/tree/main
|
|
99
|
+
changelog_uri: https://github.com/ydah/yang_json/blob/main/CHANGELOG.md
|
|
100
|
+
rubygems_mfa_required: 'true'
|
|
101
|
+
rdoc_options: []
|
|
102
|
+
require_paths:
|
|
103
|
+
- lib
|
|
104
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '3.3'
|
|
109
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
110
|
+
requirements:
|
|
111
|
+
- - ">="
|
|
112
|
+
- !ruby/object:Gem::Version
|
|
113
|
+
version: '0'
|
|
114
|
+
requirements: []
|
|
115
|
+
rubygems_version: 4.0.16
|
|
116
|
+
specification_version: 4
|
|
117
|
+
summary: Read and write RFC 7951 YANG JSON using caller-defined schemas
|
|
118
|
+
test_files: []
|