json-mask 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 +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/lib/json-mask.rb +5 -0
- data/lib/json_mask/compiled_mask.rb +26 -0
- data/lib/json_mask/error.rb +20 -0
- data/lib/json_mask/parser.rb +189 -0
- data/lib/json_mask/projector.rb +48 -0
- data/lib/json_mask/selection_tree.rb +79 -0
- data/lib/json_mask/version.rb +5 -0
- data/lib/json_mask.rb +47 -0
- metadata +58 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b68741ab4daac730737fe2b7a0f0550409d981fe7535619d2c2655a1c1a9beba
|
|
4
|
+
data.tar.gz: f65a88df55f70fcb6c2af28f97a805a0dd2a0fbd34c4d2d0975b685946ef1d53
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 46daa73f26f18e75dc6f1cc83232e33a047326801f38807c5fa2df8f16c94fdebef63778a2acce086aeb75446cb613d700d5f11dc4c9b09f7aa3e2c088b83189
|
|
7
|
+
data.tar.gz: 1ef0a561a43eb13ca3e877a7f1dde1590125fb38ff51d609e35f0bc8ee119278144cb3575cbaea19af14e08bcb742c6d183886dfe9f21edf270c9edc7981bb43
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 - Unreleased
|
|
4
|
+
|
|
5
|
+
- Implement Google partial-response and JSON Mask field selectors.
|
|
6
|
+
- Support comma-separated fields, slash paths, sub-selections, wildcards, and escaping.
|
|
7
|
+
- Add reusable compiled masks and parser resource limits.
|
|
8
|
+
- Add a `json-mask` entry file so Bundler's default require works without a `require:` option.
|
|
9
|
+
- Preserve `nil` values under nested selections, matching the reference implementation.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Sheldon
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# JSON Mask for Ruby
|
|
2
|
+
|
|
3
|
+
[](https://github.com/wistia/json-mask-ruby/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
`json-mask` selects fields from JSON-compatible Ruby objects while preserving the shape of the
|
|
6
|
+
response. It implements the field selector language used by Google's partial responses and the
|
|
7
|
+
[JSON Mask](https://github.com/nemtsov/json-mask) project.
|
|
8
|
+
|
|
9
|
+
The library has no runtime dependencies.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add the gem to your `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "json-mask"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run `bundle install`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "json_mask"
|
|
25
|
+
|
|
26
|
+
response = {
|
|
27
|
+
"id" => "abc123",
|
|
28
|
+
"name" => "Product demo",
|
|
29
|
+
"permissions" => [
|
|
30
|
+
{"id" => "owner", "role" => "owner", "email" => "owner@example.com"}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
JsonMask.call(response, "id,permissions(id,role)")
|
|
35
|
+
# => {
|
|
36
|
+
# "id" => "abc123",
|
|
37
|
+
# "permissions" => [{"id" => "owner", "role" => "owner"}]
|
|
38
|
+
# }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`JsonMask.mask` is an alias for `JsonMask.call`.
|
|
42
|
+
|
|
43
|
+
Compile selectors that will be reused:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
mask = JsonMask.compile("id,name,permissions(role)")
|
|
47
|
+
|
|
48
|
+
mask.call(first_response)
|
|
49
|
+
mask.call(second_response)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Compiled masks are immutable and safe to share between threads.
|
|
53
|
+
|
|
54
|
+
Passing `nil`, an empty string, or a whitespace-only string returns the original value unchanged.
|
|
55
|
+
This makes an optional HTTP `fields` parameter straightforward:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
render json: JsonMask.call(payload, params[:fields])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Selector syntax
|
|
62
|
+
|
|
63
|
+
The syntax is loosely based on XPath:
|
|
64
|
+
|
|
65
|
+
| Selector | Meaning |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `id,name` | Select multiple fields |
|
|
68
|
+
| `permissions/role` | Select a nested field |
|
|
69
|
+
| `permissions(id,role)` | Select multiple fields from an object or each object in an array |
|
|
70
|
+
| `permissions/*` | Select every field below `permissions` |
|
|
71
|
+
| `items/*/id` | Select `id` from every value below `items` |
|
|
72
|
+
|
|
73
|
+
Slash paths and parenthesized sub-selections traverse arrays transparently. Empty hashes remain in
|
|
74
|
+
arrays, preserving their positions. Missing fields are omitted.
|
|
75
|
+
|
|
76
|
+
Backslash escapes structural characters in field names:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
JsonMask.call({"a/b" => 1, "other" => 2}, 'a\/b')
|
|
80
|
+
# => {"a/b" => 1}
|
|
81
|
+
|
|
82
|
+
JsonMask.call({"*" => 1, "other" => 2}, '\\*')
|
|
83
|
+
# => {"*" => 1}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The structural characters are `,`, `/`, `(`, `)`, `*`, and `\\`. An asterisk is a wildcard only
|
|
87
|
+
when it is the entire, unescaped field name. Unescaped whitespace around field names and operators
|
|
88
|
+
is ignored; whitespace inside a field name is preserved.
|
|
89
|
+
|
|
90
|
+
String and symbol hash keys are supported, and the result preserves the key objects from the input.
|
|
91
|
+
The input is never mutated.
|
|
92
|
+
|
|
93
|
+
## Invalid selectors and limits
|
|
94
|
+
|
|
95
|
+
Malformed selectors raise `JsonMask::ParseError`, which includes the original expression and the
|
|
96
|
+
zero-based character offset:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
JsonMask.compile("files(id,,name)")
|
|
100
|
+
# raises JsonMask::ParseError: expected a field name at offset 9
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The parser applies conservative defaults suitable for accepting selectors from HTTP or MCP clients:
|
|
104
|
+
|
|
105
|
+
- Maximum selector length: 16,384 bytes
|
|
106
|
+
- Maximum nesting depth: 64
|
|
107
|
+
- Maximum field selectors: 1,000
|
|
108
|
+
|
|
109
|
+
The limits can be tightened for a specific boundary:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
JsonMask.compile(fields, max_length: 1_024, max_depth: 16, max_selectors: 100)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Exceeding a limit raises `JsonMask::LimitError`, a subclass of `JsonMask::ParseError`.
|
|
116
|
+
|
|
117
|
+
Validation is syntactic. Because the library has no response schema, a well-formed selector that
|
|
118
|
+
names a field absent from the input simply omits that field; it cannot produce Google's
|
|
119
|
+
schema-aware "Invalid field selection" error on its own.
|
|
120
|
+
|
|
121
|
+
## Compatibility
|
|
122
|
+
|
|
123
|
+
The supported grammar follows the [Google Drive `fields` parameter rules](https://developers.google.com/workspace/drive/api/guides/fields-parameter)
|
|
124
|
+
and JSON Mask's documented grammar. This library intentionally validates malformed expressions
|
|
125
|
+
instead of attempting to recover from them.
|
|
126
|
+
|
|
127
|
+
The projector accepts JSON-compatible `Hash` and `Array` values. If a selected field contains a
|
|
128
|
+
scalar where the selector asks for nested fields, that field is omitted — except `nil`, which
|
|
129
|
+
passes through unchanged (matching the reference implementation), so a nullable field stays
|
|
130
|
+
distinguishable from an unselected one. A non-container root value with a non-empty selector
|
|
131
|
+
produces `nil`.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
bundle install
|
|
137
|
+
bundle exec rake
|
|
138
|
+
bundle exec rake build
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The default Rake task runs the full test suite and RuboCop.
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT. See [LICENSE.txt](LICENSE.txt).
|
data/lib/json-mask.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsonMask
|
|
4
|
+
# An immutable, reusable field selector.
|
|
5
|
+
class CompiledMask
|
|
6
|
+
attr_reader :fields
|
|
7
|
+
|
|
8
|
+
def initialize(fields, selection_tree)
|
|
9
|
+
@fields = fields&.dup&.freeze
|
|
10
|
+
@selection_tree = selection_tree
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Filters a JSON-compatible value using this selector.
|
|
15
|
+
#
|
|
16
|
+
# @param value [Hash, Array] response data to filter
|
|
17
|
+
# @return [Hash, Array, nil] a structural subset of value
|
|
18
|
+
def call(value)
|
|
19
|
+
return value unless @selection_tree
|
|
20
|
+
|
|
21
|
+
Projector.call(value, @selection_tree)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
alias filter call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsonMask
|
|
4
|
+
# Raised when a field selector does not conform to the supported grammar.
|
|
5
|
+
class ParseError < ArgumentError
|
|
6
|
+
attr_reader :expression, :offset, :reason
|
|
7
|
+
|
|
8
|
+
def initialize(reason, expression:, offset:)
|
|
9
|
+
@reason = reason
|
|
10
|
+
@expression = expression
|
|
11
|
+
@offset = offset
|
|
12
|
+
|
|
13
|
+
super("#{reason} at offset #{offset}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when a field selector exceeds a configured parser limit.
|
|
18
|
+
class LimitError < ParseError
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsonMask
|
|
4
|
+
# Compiles a field selector string into an immutable selection tree.
|
|
5
|
+
class Parser
|
|
6
|
+
DEFAULT_MAX_LENGTH = 16_384
|
|
7
|
+
DEFAULT_MAX_DEPTH = 64
|
|
8
|
+
DEFAULT_MAX_SELECTORS = 1_000
|
|
9
|
+
|
|
10
|
+
TERMINALS = [',', '/', '(', ')'].freeze
|
|
11
|
+
WHITESPACE = [' ', "\t", "\n", "\r"].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(expression, max_length:, max_depth:, max_selectors:)
|
|
14
|
+
validate_expression!(expression)
|
|
15
|
+
validate_limit!(:max_length, max_length)
|
|
16
|
+
validate_limit!(:max_depth, max_depth)
|
|
17
|
+
validate_limit!(:max_selectors, max_selectors)
|
|
18
|
+
|
|
19
|
+
@expression = expression
|
|
20
|
+
@max_length = max_length
|
|
21
|
+
@max_depth = max_depth
|
|
22
|
+
@max_selectors = max_selectors
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse
|
|
26
|
+
return if @expression.nil?
|
|
27
|
+
|
|
28
|
+
enforce_length!
|
|
29
|
+
return if @expression.strip.empty?
|
|
30
|
+
|
|
31
|
+
@characters = @expression.each_char.to_a
|
|
32
|
+
@index = 0
|
|
33
|
+
@selector_count = 0
|
|
34
|
+
parse_list(depth: 1)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_list(depth:, terminator: nil)
|
|
40
|
+
tree = SelectionTree.empty
|
|
41
|
+
expect_selection!(terminator)
|
|
42
|
+
|
|
43
|
+
loop do
|
|
44
|
+
tree = tree.merge(parse_selection(depth))
|
|
45
|
+
skip_whitespace
|
|
46
|
+
|
|
47
|
+
break if finish_list?(terminator)
|
|
48
|
+
|
|
49
|
+
expect_character!(',')
|
|
50
|
+
advance
|
|
51
|
+
expect_selection!(terminator)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
tree
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_selection(depth)
|
|
58
|
+
enforce_depth!(depth)
|
|
59
|
+
name, wildcard = parse_name
|
|
60
|
+
count_selector!
|
|
61
|
+
skip_whitespace
|
|
62
|
+
|
|
63
|
+
children = case current_character
|
|
64
|
+
when '/'
|
|
65
|
+
advance
|
|
66
|
+
parse_selection(depth + 1)
|
|
67
|
+
when '('
|
|
68
|
+
advance
|
|
69
|
+
parse_list(depth: depth + 1, terminator: ')')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
SelectionTree.single(name:, wildcard:, children:)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_name
|
|
76
|
+
skip_whitespace
|
|
77
|
+
entries = []
|
|
78
|
+
|
|
79
|
+
while current_character && !TERMINALS.include?(current_character)
|
|
80
|
+
entries << next_name_character
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
entries.pop while trailing_whitespace?(entries.last)
|
|
84
|
+
parse_error!('expected a field name') if entries.empty?
|
|
85
|
+
|
|
86
|
+
name = entries.map(&:first).join
|
|
87
|
+
wildcard = entries == [['*', false]]
|
|
88
|
+
[name, wildcard]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def next_name_character
|
|
92
|
+
character = current_character
|
|
93
|
+
unless character == '\\'
|
|
94
|
+
advance
|
|
95
|
+
return [character, false]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
advance
|
|
99
|
+
return ['\\', false] unless current_character
|
|
100
|
+
|
|
101
|
+
character = current_character
|
|
102
|
+
advance
|
|
103
|
+
[character, true]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def finish_list?(terminator)
|
|
107
|
+
if terminator && current_character == terminator
|
|
108
|
+
advance
|
|
109
|
+
return true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return true if current_character.nil? && terminator.nil?
|
|
113
|
+
|
|
114
|
+
parse_error!("expected #{terminator.inspect}") if current_character.nil?
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def expect_selection!(terminator)
|
|
119
|
+
skip_whitespace
|
|
120
|
+
unless current_character.nil? || current_character == ',' || current_character == terminator
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
parse_error!('expected a field name')
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def expect_character!(expected)
|
|
128
|
+
return if current_character == expected
|
|
129
|
+
|
|
130
|
+
parse_error!("expected #{expected.inspect}")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def skip_whitespace
|
|
134
|
+
advance while WHITESPACE.include?(current_character)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def trailing_whitespace?(entry)
|
|
138
|
+
entry && !entry.last && WHITESPACE.include?(entry.first)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def current_character
|
|
142
|
+
@characters[@index]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def advance
|
|
146
|
+
@index += 1
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def count_selector!
|
|
150
|
+
@selector_count += 1
|
|
151
|
+
return if @selector_count <= @max_selectors
|
|
152
|
+
|
|
153
|
+
limit_error!("selector contains more than #{@max_selectors} fields")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def enforce_length!
|
|
157
|
+
return if @expression.bytesize <= @max_length
|
|
158
|
+
|
|
159
|
+
limit_error!("selector is longer than #{@max_length} bytes", offset: 0)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def enforce_depth!(depth)
|
|
163
|
+
return if depth <= @max_depth
|
|
164
|
+
|
|
165
|
+
limit_error!("selector nesting is deeper than #{@max_depth}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_expression!(expression)
|
|
169
|
+
return if expression.nil?
|
|
170
|
+
|
|
171
|
+
raise TypeError, 'fields must be a String or nil' unless expression.is_a?(String)
|
|
172
|
+
raise ArgumentError, 'fields must use valid UTF-8' unless expression.valid_encoding?
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def validate_limit!(name, value)
|
|
176
|
+
return if value.is_a?(Integer) && value.positive?
|
|
177
|
+
|
|
178
|
+
raise ArgumentError, "#{name} must be a positive Integer"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_error!(reason, offset: @index)
|
|
182
|
+
raise ParseError.new(reason, expression: @expression, offset:)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def limit_error!(reason, offset: @index || 0)
|
|
186
|
+
raise LimitError.new(reason, expression: @expression, offset:)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsonMask
|
|
4
|
+
# Applies a compiled selection tree to Hash and Array values.
|
|
5
|
+
module Projector
|
|
6
|
+
MISSING = Object.new.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(value, selection_tree)
|
|
11
|
+
projected = project(value, selection_tree)
|
|
12
|
+
projected.equal?(MISSING) ? nil : projected
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def project(value, selection_tree)
|
|
16
|
+
case value
|
|
17
|
+
when Hash
|
|
18
|
+
project_hash(value, selection_tree)
|
|
19
|
+
when Array
|
|
20
|
+
project_array(value, selection_tree)
|
|
21
|
+
when nil
|
|
22
|
+
# JSON null is a value, not a shape mismatch: a nested selection into
|
|
23
|
+
# null keeps the null (matching the reference implementation), so a
|
|
24
|
+
# nullable field stays distinguishable from an unselected one.
|
|
25
|
+
nil
|
|
26
|
+
else
|
|
27
|
+
MISSING
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def project_hash(value, selection_tree)
|
|
32
|
+
value.each_with_object({}) do |(key, field_value), result|
|
|
33
|
+
selection = selection_tree.selection_for(key)
|
|
34
|
+
next unless selection
|
|
35
|
+
|
|
36
|
+
projected = selection.leaf? ? field_value : project(field_value, selection.children)
|
|
37
|
+
result[key] = projected unless projected.equal?(MISSING)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def project_array(value, selection_tree)
|
|
42
|
+
value.each_with_object([]) do |item, result|
|
|
43
|
+
projected = project(item, selection_tree)
|
|
44
|
+
result << projected unless projected.equal?(MISSING)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JsonMask
|
|
4
|
+
# Represents either a complete field or a field with nested selections.
|
|
5
|
+
class Selection
|
|
6
|
+
attr_reader :children
|
|
7
|
+
|
|
8
|
+
def self.leaf
|
|
9
|
+
@leaf ||= new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(children = nil)
|
|
13
|
+
@children = children
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def leaf?
|
|
18
|
+
children.nil?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def merge(other)
|
|
22
|
+
return self.class.leaf if leaf? || other.leaf?
|
|
23
|
+
|
|
24
|
+
self.class.new(children.merge(other.children))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Stores named and wildcard selections, merging repeated selections by union.
|
|
29
|
+
class SelectionTree
|
|
30
|
+
attr_reader :named, :wildcard
|
|
31
|
+
|
|
32
|
+
def self.empty
|
|
33
|
+
@empty ||= new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.single(name:, wildcard:, children: nil)
|
|
37
|
+
selection = children ? Selection.new(children) : Selection.leaf
|
|
38
|
+
|
|
39
|
+
if wildcard
|
|
40
|
+
new(wildcard: selection)
|
|
41
|
+
else
|
|
42
|
+
new(named: { name => selection })
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize(named: {}, wildcard: nil)
|
|
47
|
+
@named = named.freeze
|
|
48
|
+
@wildcard = wildcard
|
|
49
|
+
@effective_named = effective_named.freeze
|
|
50
|
+
freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def merge(other)
|
|
54
|
+
self.class.new(
|
|
55
|
+
named: named.merge(other.named) { |_name, left, right| left.merge(right) },
|
|
56
|
+
wildcard: merge_wildcards(other)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def selection_for(key)
|
|
61
|
+
@effective_named.fetch(key.to_s, wildcard)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def effective_named
|
|
67
|
+
return named unless wildcard
|
|
68
|
+
|
|
69
|
+
named.transform_values { |selection| selection.merge(wildcard) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def merge_wildcards(other)
|
|
73
|
+
return other.wildcard unless wildcard
|
|
74
|
+
return wildcard unless other.wildcard
|
|
75
|
+
|
|
76
|
+
wildcard.merge(other.wildcard)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/json_mask.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'json_mask/version'
|
|
4
|
+
require_relative 'json_mask/error'
|
|
5
|
+
require_relative 'json_mask/selection_tree'
|
|
6
|
+
require_relative 'json_mask/parser'
|
|
7
|
+
require_relative 'json_mask/projector'
|
|
8
|
+
require_relative 'json_mask/compiled_mask'
|
|
9
|
+
|
|
10
|
+
# Filters JSON-compatible Ruby values using Google partial-response selectors.
|
|
11
|
+
module JsonMask
|
|
12
|
+
class << self
|
|
13
|
+
# Filters a JSON-compatible value with a field selector.
|
|
14
|
+
#
|
|
15
|
+
# @param value [Hash, Array] response data to filter
|
|
16
|
+
# @param fields [String, nil] fields selector; blank values pass through unchanged
|
|
17
|
+
# @return [Hash, Array, nil] a structural subset of value
|
|
18
|
+
def call(value, fields, **options)
|
|
19
|
+
compile(fields, **options).call(value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
alias mask call
|
|
23
|
+
|
|
24
|
+
# Compiles a selector for reuse across multiple values.
|
|
25
|
+
#
|
|
26
|
+
# @return [CompiledMask] an immutable selector
|
|
27
|
+
# @raise [ParseError] if the selector is malformed
|
|
28
|
+
# @raise [LimitError] if the selector exceeds a configured limit
|
|
29
|
+
def compile(
|
|
30
|
+
fields,
|
|
31
|
+
max_length: Parser::DEFAULT_MAX_LENGTH,
|
|
32
|
+
max_depth: Parser::DEFAULT_MAX_DEPTH,
|
|
33
|
+
max_selectors: Parser::DEFAULT_MAX_SELECTORS
|
|
34
|
+
)
|
|
35
|
+
selection_tree = Parser.new(
|
|
36
|
+
fields,
|
|
37
|
+
max_length:,
|
|
38
|
+
max_depth:,
|
|
39
|
+
max_selectors:
|
|
40
|
+
).parse
|
|
41
|
+
|
|
42
|
+
CompiledMask.new(fields, selection_tree)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private_constant :Parser, :Projector, :Selection, :SelectionTree
|
|
47
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: json-mask
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Robert Sheldon
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
A dependency-free implementation of the Google partial-response and JSON Mask fields
|
|
14
|
+
language for filtering Hash and Array response data.
|
|
15
|
+
email:
|
|
16
|
+
- rsheldon@wistia.com
|
|
17
|
+
executables: []
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- CHANGELOG.md
|
|
22
|
+
- LICENSE.txt
|
|
23
|
+
- README.md
|
|
24
|
+
- lib/json-mask.rb
|
|
25
|
+
- lib/json_mask.rb
|
|
26
|
+
- lib/json_mask/compiled_mask.rb
|
|
27
|
+
- lib/json_mask/error.rb
|
|
28
|
+
- lib/json_mask/parser.rb
|
|
29
|
+
- lib/json_mask/projector.rb
|
|
30
|
+
- lib/json_mask/selection_tree.rb
|
|
31
|
+
- lib/json_mask/version.rb
|
|
32
|
+
homepage: https://github.com/wistia/json-mask-ruby
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
bug_tracker_uri: https://github.com/wistia/json-mask-ruby/issues
|
|
37
|
+
changelog_uri: https://github.com/wistia/json-mask-ruby/blob/main/CHANGELOG.md
|
|
38
|
+
documentation_uri: https://rubydoc.info/gems/json-mask
|
|
39
|
+
rubygems_mfa_required: 'true'
|
|
40
|
+
source_code_uri: https://github.com/wistia/json-mask-ruby
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '3.1'
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 4.0.16
|
|
56
|
+
specification_version: 4
|
|
57
|
+
summary: Select fields from JSON-compatible Ruby objects without changing their shape
|
|
58
|
+
test_files: []
|