diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d5f3bb..412df35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a27debb..70e06b3 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/json_mask.rb b/lib/json_mask.rb index 102e6ac..bf7f476 100644 --- a/lib/json_mask.rb +++ b/lib/json_mask.rb @@ -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. @@ -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 diff --git a/lib/json_mask/compiled_mask.rb b/lib/json_mask/compiled_mask.rb index ebd6747..ca911f7 100644 --- a/lib/json_mask/compiled_mask.rb +++ b/lib/json_mask/compiled_mask.rb @@ -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 @@ -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 diff --git a/lib/json_mask/exclusion_projector.rb b/lib/json_mask/exclusion_projector.rb new file mode 100644 index 0000000..233cc57 --- /dev/null +++ b/lib/json_mask/exclusion_projector.rb @@ -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 diff --git a/lib/json_mask/version.rb b/lib/json_mask/version.rb index a7ad417..141f165 100644 --- a/lib/json_mask/version.rb +++ b/lib/json_mask/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JsonMask - VERSION = '0.1.0' + VERSION = '0.2.0' end diff --git a/test/json_mask_test.rb b/test/json_mask_test.rb index 4ba6706..0946ddc 100644 --- a/test/json_mask_test.rb +++ b/test/json_mask_test.rb @@ -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