Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.2.0 - Unreleased

- Add `JsonMask.except` and `JsonMask.compile_except`, which apply a selector as an exclusion:
matched fields are removed and every other field passes through unchanged.

## 0.1.0 - Unreleased

- Implement Google partial-response and JSON Mask field selectors.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,36 @@ This makes an optional HTTP `fields` parameter straightforward:
render json: JsonMask.call(payload, params[:fields])
```

## Exclusion masks

`JsonMask.except` applies a selector as an exclusion: matched fields are removed and every other
field passes through unchanged.

```ruby
JsonMask.except(response, "permissions/email")
# => {
# "id" => "abc123",
# "name" => "Product demo",
# "permissions" => [{"id" => "owner", "role" => "owner"}]
# }
```

`JsonMask.compile_except` compiles a reusable exclusion mask, mirroring `JsonMask.compile`:

```ruby
mask = JsonMask.compile_except("email,phone_number")

mask.call(first_response)
mask.call(second_response)
```

Exclusion masks use the same selector grammar, parser limits, and traversal rules as selection.
Sub-selections and slash paths remove nested fields while keeping their parents
(`permissions(email)` keeps `permissions` but removes each entry's `email`), arrays are traversed
transparently, and a wildcard removes every field at its level. Selectors naming missing fields
are a no-op, scalars under a nested exclusion are kept as-is, and a `nil` or blank selector
removes nothing.

## Selector syntax

The syntax is loosely based on XPath:
Expand Down
23 changes: 22 additions & 1 deletion lib/json_mask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative 'json_mask/selection_tree'
require_relative 'json_mask/parser'
require_relative 'json_mask/projector'
require_relative 'json_mask/exclusion_projector'
require_relative 'json_mask/compiled_mask'

# Filters JSON-compatible Ruby values using Google partial-response selectors.
Expand Down Expand Up @@ -41,7 +42,27 @@ def compile(

CompiledMask.new(fields, selection_tree)
end

def except(value, fields, **options)
compile_except(fields, **options).call(value)
end

def compile_except(
fields,
max_length: Parser::DEFAULT_MAX_LENGTH,
max_depth: Parser::DEFAULT_MAX_DEPTH,
max_selectors: Parser::DEFAULT_MAX_SELECTORS
)
selection_tree = Parser.new(
fields,
max_length:,
max_depth:,
max_selectors:
).parse

CompiledMask.new(fields, selection_tree, projector: ExclusionProjector)
end
end

private_constant :Parser, :Projector, :Selection, :SelectionTree
private_constant :Parser, :Projector, :ExclusionProjector, :Selection, :SelectionTree
end
5 changes: 3 additions & 2 deletions lib/json_mask/compiled_mask.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ module JsonMask
class CompiledMask
attr_reader :fields

def initialize(fields, selection_tree)
def initialize(fields, selection_tree, projector: Projector)
@fields = fields&.dup&.freeze
@selection_tree = selection_tree
@projector = projector
freeze
end

Expand All @@ -18,7 +19,7 @@ def initialize(fields, selection_tree)
def call(value)
return value unless @selection_tree

Projector.call(value, @selection_tree)
@projector.call(value, @selection_tree)
end

alias filter call
Expand Down
40 changes: 40 additions & 0 deletions lib/json_mask/exclusion_projector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true

module JsonMask
# Applies a compiled selection tree as an exclusion: matched fields are
# removed and every other field passes through unchanged.
module ExclusionProjector
module_function

def call(value, selection_tree)
exclude(value, selection_tree)
end

def exclude(value, selection_tree)
case value
when Hash
exclude_hash(value, selection_tree)
when Array
exclude_array(value, selection_tree)
else
value
end
end

def exclude_hash(value, selection_tree)
value.each_with_object({}) do |(key, field_value), result|
selection = selection_tree.selection_for(key)

if selection.nil?
result[key] = field_value
elsif !selection.leaf?
result[key] = exclude(field_value, selection.children)
end
end
end

def exclude_array(value, selection_tree)
value.map { |item| exclude(item, selection_tree) }
end
end
end
2 changes: 1 addition & 1 deletion lib/json_mask/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module JsonMask
VERSION = '0.1.0'
VERSION = '0.2.0'
end
117 changes: 117 additions & 0 deletions test/json_mask_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,121 @@ def test_limits_must_be_positive_integers

assert_equal 'max_depth must be a positive Integer', error.message
end

def test_except_removes_top_level_fields
input = { 'id' => 1, 'name' => 'Demo', 'email' => 'owner@example.com' }

assert_equal({ 'id' => 1, 'name' => 'Demo' }, JsonMask.except(input, 'email'))
end

def test_except_removes_nested_fields_with_slashes
input = {
'name' => 'Demo',
'capabilities' => { 'canDownload' => true, 'canEdit' => false }
}

expected = { 'name' => 'Demo', 'capabilities' => { 'canDownload' => true } }

assert_equal expected, JsonMask.except(input, 'capabilities/canEdit')
end

def test_except_subselections_traverse_objects_and_arrays
input = {
'files' => [
{
'id' => 'one',
'permissions' => [{ 'role' => 'owner', 'email' => 'owner@example.com' }]
},
{ 'id' => 'two', 'permissions' => [] }
],
'nextPageToken' => 'next'
}

expected = {
'files' => [
{ 'id' => 'one', 'permissions' => [{ 'role' => 'owner' }] },
{ 'id' => 'two', 'permissions' => [] }
],
'nextPageToken' => 'next'
}

assert_equal expected, JsonMask.except(input, 'files(permissions(email))')
end

def test_except_removes_whole_subtrees
input = { 'id' => 1, 'permissions' => [{ 'role' => 'owner' }] }

assert_equal({ 'id' => 1 }, JsonMask.except(input, 'permissions'))
end

def test_except_applies_to_each_item_of_a_top_level_array
input = [
{ 'id' => 1, 'email' => 'a@example.com' },
{ 'id' => 2, 'email' => 'b@example.com' }
]

assert_equal [{ 'id' => 1 }, { 'id' => 2 }], JsonMask.except(input, 'email')
end

def test_except_keeps_scalars_and_nils_under_nested_exclusions
input = { 'a' => 1, 'b' => nil, 'c' => { 'x' => 1, 'y' => 2 } }

expected = { 'a' => 1, 'b' => nil, 'c' => { 'x' => 1 } }

assert_equal expected, JsonMask.except(input, 'a/x,b/x,c/y')
end

def test_except_missing_fields_are_a_noop
input = { 'id' => 1 }

assert_equal({ 'id' => 1 }, JsonMask.except(input, 'email,contact/email'))
end

def test_except_wildcard_removes_every_field
input = { 'id' => 1, 'nested' => { 'x' => 1 } }

assert_empty JsonMask.except(input, '*')
end

def test_except_nested_wildcard_empties_the_field
input = { 'id' => 1, 'nested' => { 'x' => 1, 'y' => 2 } }

assert_equal({ 'id' => 1, 'nested' => {} }, JsonMask.except(input, 'nested/*'))
end

def test_except_with_blank_selectors_returns_the_value_unchanged
input = { 'id' => 1 }

assert_same input, JsonMask.except(input, nil)
assert_same input, JsonMask.except(input, '')
assert_same input, JsonMask.except(input, " \n\t")
end

def test_except_preserves_symbol_keys_and_does_not_mutate_the_input
input = { id: 1, email: 'owner@example.com' }

assert_equal({ id: 1 }, JsonMask.except(input, 'email'))
assert_equal({ id: 1, email: 'owner@example.com' }, input)
end

def test_compiled_exclusion_masks_can_be_reused
mask = JsonMask.compile_except('email')

assert_predicate mask, :frozen?
assert_equal 'email', mask.fields
assert_equal({ 'id' => 1 }, mask.call({ 'id' => 1, 'email' => 'a@example.com' }))
assert_equal({ 'id' => 2 }, mask.filter({ 'id' => 2, 'email' => 'b@example.com' }))
end

def test_compile_except_enforces_limits
assert_raises(JsonMask::LimitError) { JsonMask.compile_except('abcdef', max_length: 5) }
assert_raises(JsonMask::LimitError) { JsonMask.compile_except('a/b/c', max_depth: 2) }
assert_raises(JsonMask::LimitError) { JsonMask.compile_except('a,b,c', max_selectors: 2) }
end

def test_compile_except_rejects_malformed_selectors
error = assert_raises(JsonMask::ParseError) { JsonMask.compile_except('files(id') }

assert_equal 'files(id', error.expression
end
end