TestAnyGetterFiltering.java

package tools.jackson.databind.ser.filter;

import java.util.*;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.*;

import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.BeanDescription;
import tools.jackson.databind.DatabindException;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.SerializationConfig;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.SerializationFeature;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.module.SimpleModule;
import tools.jackson.databind.node.JsonNodeFactory;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.jsonFormatVisitors.JsonObjectFormatVisitor;
import tools.jackson.databind.ser.BeanPropertyWriter;
import tools.jackson.databind.ser.BeanSerializer;
import tools.jackson.databind.ser.FilterProvider;
import tools.jackson.databind.ser.PropertyFilter;
import tools.jackson.databind.ser.PropertyWriter;
import tools.jackson.databind.ser.ValueSerializerModifier;
import tools.jackson.databind.ser.bean.BeanSerializerBase;
import tools.jackson.databind.ser.std.SimpleBeanPropertyFilter;
import tools.jackson.databind.ser.std.SimpleFilterProvider;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * Unit tests for ensuring that entries accessible via "any filter"
 * can also be filtered with JSON Filter functionality.
 */
public class TestAnyGetterFiltering extends DatabindTestUtil
{
    @JsonFilter("anyFilter")
    public static class AnyBean
    {
        private Map<String, String> properties = new HashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("b", "2");
        }

        @JsonAnyGetter
        public Map<String, String> anyProperties()
        {
            return properties;
        }
    }

    public static class AnyBeanWithIgnores
    {
        private Map<String, String> properties = new LinkedHashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("bogus", "2");
            properties.put("b", "3");
        }

        @JsonAnyGetter
        @JsonIgnoreProperties({ "bogus" })
        public Map<String, String> anyProperties()
        {
            return properties;
        }
    }

    // [databind#1281]
    public static class AnyBeanWithMultipleIgnores
    {
        public String name = "bob";

        private Map<String, String> properties = new LinkedHashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("secret", "s");
            properties.put("b", "2");
            properties.put("internal", "i");
        }

        @JsonAnyGetter
        @JsonIgnoreProperties({ "secret", "internal" })
        public Map<String, String> anyProperties()
        {
            return properties;
        }
    }

    // [databind#6136]
    @JsonFilter("anyFilter")
    static class AnyBeanWithSecret
    {
        public String name = "bob";

        private Map<String, String> properties = new LinkedHashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("secret", "s3cr3t");
        }

        @JsonAnyGetter
        public Map<String, String> anyProperties() {
            return properties;
        }
    }

    // [databind#6136]
    @JsonFilter("anyFilter")
    static class ObjectNodeAnyBeanWithSecret
    {
        public String name = "bob";

        @JsonAnyGetter
        public ObjectNode anyProperties() {
            return JsonNodeFactory.instance.objectNode()
                    .put("a", "1")
                    .put("secret", "s3cr3t");
        }
    }

    // [databind#6136]: content inclusion has to apply on filtered path too,
    // same as it does without a filter (see `NonEmptyAnyBean`)
    @JsonFilter("anyFilter")
    static class FilteredNonEmptyAnyBean
    {
        public String name = "bob";

        @JsonAnyGetter
        @JsonInclude(content = JsonInclude.Include.NON_EMPTY)
        public Map<String, String> anyProperties() {
            Map<String, String> props = new LinkedHashMap<>();
            props.put("a", "1");
            props.put("blank", "");
            return props;
        }
    }

    // [databind#6136]: same as `FilteredNonEmptyAnyBean` but without `@JsonFilter`
    static class NonEmptyAnyBean
    {
        public String name = "bob";

        @JsonAnyGetter
        @JsonInclude(content = JsonInclude.Include.NON_EMPTY)
        public Map<String, String> anyProperties() {
            Map<String, String> props = new LinkedHashMap<>();
            props.put("a", "1");
            props.put("blank", "");
            return props;
        }
    }

    // [databind#6136]: view-gated any-getter (accessor gets wrapped in a
    // view-filtering writer when a view is active) must still filter per entry
    static class Views {
        static class Public { }
        static class Internal { }
        static class Other { }
    }

    @JsonFilter("anyFilter")
    static class ViewAnyBeanWithSecret
    {
        @JsonView(Views.Public.class)
        public String name = "bob";

        private Map<String, String> properties = new LinkedHashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("secret", "s3cr3t");
        }

        @JsonView(Views.Public.class)
        @JsonAnyGetter
        public Map<String, String> anyProperties() {
            return properties;
        }
    }

    // [databind#6136]: same but any-getter in multiple views (different wrapper)
    @JsonFilter("anyFilter")
    static class MultiViewAnyBeanWithSecret
    {
        @JsonView({ Views.Public.class, Views.Internal.class, Views.Other.class })
        public String name = "bob";

        private Map<String, String> properties = new LinkedHashMap<String, String>();
        {
            properties.put("a", "1");
            properties.put("secret", "s3cr3t");
        }

        @JsonView({ Views.Public.class, Views.Internal.class })
        @JsonAnyGetter
        public Map<String, String> anyProperties() {
            return properties;
        }
    }

    // [databind#6136]: filtered any-getter must still honor Map entry ordering
    @JsonFilter("anyFilter")
    static class UnsortedAnyBean
    {
        public int a = 1;

        @JsonAnyGetter
        public Map<String, Integer> anyProperties() {
            Map<String, Integer> m = new LinkedHashMap<>();
            m.put("z", 1);
            m.put("b", 2);
            return m;
        }
    }

    // [databind#6136]: any-getter with its own filter, in addition to class filter
    @JsonFilter("anyFilter")
    static class MapFilteredAnyBean
    {
        public int a = 1;

        @JsonFilter("mapFilter")
        @JsonAnyGetter
        public Map<String, Integer> anyProperties() {
            Map<String, Integer> m = new LinkedHashMap<>();
            m.put("x", 1);
            m.put("y", 2);
            m.put("secret", 3);
            return m;
        }
    }

    // [databind#6136]: serializer that (like `XmlBeanSerializerBase`) calls
    // `PropertyFilter` directly with each writer, including `AnyGetterWriter`
    static class DirectFilterCallingSerializer extends BeanSerializer
    {
        DirectFilterCallingSerializer(BeanSerializerBase src) {
            super(src);
        }

        @Override
        protected void _serializePropertiesFiltered(Object bean, JsonGenerator g,
                SerializationContext ctxt, Object filterId)
        {
            PropertyFilter filter = findPropertyFilter(ctxt, filterId, bean);
            try {
                for (BeanPropertyWriter prop : _props) {
                    filter.serializeAsProperty(bean, g, ctxt, prop);
                }
            } catch (Exception e) {
                wrapAndThrow(ctxt, e, bean, "?");
            }
        }
    }

    // [databind#1655]
    @JsonFilter("CustomFilter")
    static class OuterObject {
         public int getExplicitProperty() {
              return 42;
         }

         @JsonAnyGetter
         public Map<String, Object> getAny() {
              Map<String, Object> extra = new HashMap<>();
              extra.put("dynamicProperty", "I will not serialize");
              return extra;
         }
    }

    // [databind#6136]: filter that implements `PropertyFilter` directly instead of
    // extending `SimpleBeanPropertyFilter` -- must also get per-entry decisions
    static class DirectExcludingFilter implements PropertyFilter
    {
        private final Set<String> _excluded;

        public DirectExcludingFilter(String... names) {
            _excluded = new HashSet<>(Arrays.asList(names));
        }

        @Override
        public PropertyFilter snapshot() { return this; }

        @Override
        public void serializeAsProperty(Object pojo, JsonGenerator g, SerializationContext ctxt,
                PropertyWriter writer)
            throws Exception
        {
            if (!_excluded.contains(writer.getName())) {
                writer.serializeAsProperty(pojo, g, ctxt);
            }
        }

        @Override
        public void serializeAsElement(Object elementValue, JsonGenerator g, SerializationContext ctxt,
                PropertyWriter writer)
            throws Exception
        {
            writer.serializeAsElement(elementValue, g, ctxt);
        }

        @Override
        public void depositSchemaProperty(PropertyWriter writer, JsonObjectFormatVisitor v,
                SerializationContext ctxt) {
            writer.depositSchemaProperty(v, ctxt);
        }
    }

    static class CustomFilter extends SimpleBeanPropertyFilter {
         @Override
         public void serializeAsProperty(Object pojo, JsonGenerator gen, SerializationContext provider,
                 PropertyWriter writer) throws Exception
         {
             if (pojo instanceof OuterObject) {
                 writer.serializeAsProperty(pojo, gen, provider);
              }
         }
    }

    /*
    /**********************************************************
    /* Test methods
    /**********************************************************
     */

    private final ObjectMapper MAPPER = new ObjectMapper();

    @Test
    public void testAnyGetterFiltering() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.filterOutAllExcept("b"));
        assertEquals("{\"b\":\"2\"}", MAPPER.writer(prov).writeValueAsString(new AnyBean()));
    }

    // [databind#6136]
    @Test
    public void anyGetterSerializeAllExcept() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(prov).writeValueAsString(new AnyBeanWithSecret()));
    }

    // [databind#6136]: accessor name of any-getter is no longer used as filter key;
    // only names of entries are
    @Test
    public void anyGetterAccessorNameNotFilterKey() throws Exception
    {
        // used to include all entries; now none (no entry named "anyProperties")
        FilterProvider including = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.filterOutAllExcept("name", "anyProperties"));
        assertEquals("""
                {"name":"bob"}""",
                MAPPER.writer(including).writeValueAsString(new AnyBeanWithSecret()));

        // and excluding accessor name does not exclude any entries
        FilterProvider excluding = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("anyProperties"));
        assertEquals("""
                {"name":"bob","a":"1","secret":"s3cr3t"}""",
                MAPPER.writer(excluding).writeValueAsString(new AnyBeanWithSecret()));
    }

    // [databind#6136]
    @Test
    public void objectNodeAnyGetterFiltering() throws Exception
    {
        FilterProvider excluding = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(excluding).writeValueAsString(new ObjectNodeAnyBeanWithSecret()));

        FilterProvider including = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.filterOutAllExcept("name", "a"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(including).writeValueAsString(new ObjectNodeAnyBeanWithSecret()));
    }

    // [databind#6136]: also has to work for filters that do not extend
    // `SimpleBeanPropertyFilter`
    @Test
    public void anyGetterFilteringWithDirectFilterImpl() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                new DirectExcludingFilter("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(prov).writeValueAsString(new AnyBeanWithSecret()));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(prov).writeValueAsString(new ObjectNodeAnyBeanWithSecret()));
    }

    // [databind#6136]: `@JsonInclude` content inclusion of the any-getter has to be
    // honored on the filtered path as well -- filtering decides which entries a filter
    // lets through, not whether inclusion criteria apply
    @Test
    public void anyGetterContentInclusionWithFilter() throws Exception
    {
        final String EXP = """
                {"name":"bob","a":"1"}""";

        // Baseline: no filter at all, empty-valued entry suppressed
        assertEquals(EXP, MAPPER.writeValueAsString(new NonEmptyAnyBean()));

        // and same has to hold for both filter styles
        FilterProvider excluding = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals(EXP,
                MAPPER.writer(excluding).writeValueAsString(new FilteredNonEmptyAnyBean()));

        FilterProvider including = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.filterOutAllExcept("name", "a", "blank"));
        assertEquals(EXP,
                MAPPER.writer(including).writeValueAsString(new FilteredNonEmptyAnyBean()));
    }

    // [databind#6136]: excluded entries must not leak when the any-getter is gated
    // by an active JSON View (accessor gets wrapped in a view-filtering writer)
    @Test
    public void anyGetterFilteringWithActiveView() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(prov).withView(Views.Public.class)
                        .writeValueAsString(new ViewAnyBeanWithSecret()));
        // and with a view that excludes the any-getter, no entries at all
        assertEquals("{}",
                MAPPER.writer(prov).withView(Views.Other.class)
                        .writeValueAsString(new ViewAnyBeanWithSecret()));
    }

    // [databind#6136]: same for any-getter in multiple views
    @Test
    public void anyGetterFilteringWithActiveMultiView() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                MAPPER.writer(prov).withView(Views.Internal.class)
                        .writeValueAsString(new MultiViewAnyBeanWithSecret()));
        // and with a view that excludes the any-getter, no entries at all
        assertEquals("""
                {"name":"bob"}""",
                MAPPER.writer(prov).withView(Views.Other.class)
                        .writeValueAsString(new MultiViewAnyBeanWithSecret()));
    }

    // [databind#6136]: filtering must not lose ORDER_MAP_ENTRIES_BY_KEYS
    @Test
    public void anyGetterFilteringWithSortedEntries() throws Exception
    {
        ObjectMapper mapper = jsonMapperBuilder()
                .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
                .build();
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("nosuch"));
        assertEquals("""
                {"a":1,"b":2,"z":1}""",
                mapper.writer(prov).writeValueAsString(new UnsortedAnyBean()));
    }

    // [databind#6136]: both class filter and any-getter's own filter must apply
    @Test
    public void anyGetterFilteringWithMapFilter() throws Exception
    {
        FilterProvider prov = new SimpleFilterProvider()
                .addFilter("anyFilter", SimpleBeanPropertyFilter.serializeAllExcept("secret"))
                .addFilter("mapFilter", SimpleBeanPropertyFilter.filterOutAllExcept("x", "secret"));
        assertEquals("""
                {"a":1,"x":1}""",
                MAPPER.writer(prov).writeValueAsString(new MapFilteredAnyBean()));
    }

    // [databind#6136]: checked exception from class filter, when chained with
    // any-getter's own filter, must be wrapped (not lost or thrown as-is)
    @Test
    public void anyGetterFilteringWithMapFilterCheckedException() throws Exception
    {
        PropertyFilter failing = new SimpleBeanPropertyFilter() {
            @Override
            public void serializeAsProperty(Object pojo, JsonGenerator g,
                    SerializationContext ctxt, PropertyWriter writer)
                throws Exception
            {
                if ("x".equals(writer.getName())) {
                    throw new CheckedFilterException("Filter failure for 'x'");
                }
                super.serializeAsProperty(pojo, g, ctxt, writer);
            }
        };
        FilterProvider prov = new SimpleFilterProvider()
                .addFilter("anyFilter", failing)
                .addFilter("mapFilter", SimpleBeanPropertyFilter.serializeAll());
        DatabindException e = assertThrows(DatabindException.class,
                () -> MAPPER.writer(prov).writeValueAsString(new MapFilteredAnyBean()));
        assertInstanceOf(CheckedFilterException.class, e.getCause());
        verifyException(e, "Filter failure for 'x'");
    }

    static class CheckedFilterException extends Exception {
        private static final long serialVersionUID = 1L;

        public CheckedFilterException(String msg) { super(msg); }
    }

    // [databind#6136]: filtered ObjectNode any-getter must write names same as
    // unfiltered one (not via custom `String` key serializer)
    @Test
    public void objectNodeAnyGetterFilteringKeyNames() throws Exception
    {
        ObjectMapper mapper = jsonMapperBuilder()
                .addModule(new SimpleModule().addKeySerializer(String.class,
                        new ValueSerializer<String>() {
                            @Override
                            public void serialize(String value, JsonGenerator g,
                                    SerializationContext ctxt) {
                                g.writeName(value.toUpperCase());
                            }
                        }))
                .build();
        FilterProvider prov = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                mapper.writer(prov).writeValueAsString(new ObjectNodeAnyBeanWithSecret()));
    }

    // [databind#6136]: serializers that call filter directly with `AnyGetterWriter`
    // (instead of `BeanPropertyWriter.serializeFilteredAsProperty()`) must still
    // get per-entry filtering
    @Test
    public void anyGetterFilteringWithDirectFilterCall() throws Exception
    {
        ObjectMapper mapper = jsonMapperBuilder()
                .addModule(new SimpleModule().setSerializerModifier(new ValueSerializerModifier() {
                    @Override
                    public ValueSerializer<?> modifySerializer(SerializationConfig config,
                            BeanDescription.Supplier beanDesc, ValueSerializer<?> ser) {
                        if (ser instanceof BeanSerializer beanSer) {
                            return new DirectFilterCallingSerializer(beanSer);
                        }
                        return ser;
                    }
                }))
                .build();
        FilterProvider excluding = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.serializeAllExcept("secret"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                mapper.writer(excluding).writeValueAsString(new AnyBeanWithSecret()));
        FilterProvider including = new SimpleFilterProvider().addFilter("anyFilter",
                SimpleBeanPropertyFilter.filterOutAllExcept("name", "a"));
        assertEquals("""
                {"name":"bob","a":"1"}""",
                mapper.writer(including).writeValueAsString(new AnyBeanWithSecret()));
    }

    // for [databind#1142]
    @Test
    public void testAnyGetterIgnore() throws Exception
    {
        assertEquals(a2q("{'a':'1','b':'3'}"),
                MAPPER.writeValueAsString(new AnyBeanWithIgnores()));
    }

    // [databind#1281]: @JsonIgnoreProperties on @JsonAnyGetter method should
    //   filter multiple map entries, coexist with regular properties
    @Test
    public void testAnyGetterIgnoreProperties1281() throws Exception
    {
        assertEquals(a2q("{'name':'bob','a':'1','b':'2'}"),
                MAPPER.writeValueAsString(new AnyBeanWithMultipleIgnores()));
    }

    // [databind#1655]
    @Test
    public void testAnyGetterPojo1655() throws Exception
    {
        FilterProvider filters = new SimpleFilterProvider().addFilter("CustomFilter", new CustomFilter());
        String json = MAPPER.writer(filters).writeValueAsString(new OuterObject());
        Map<?,?> stuff = MAPPER.readValue(json, Map.class);
        if (stuff.size() != 2) {
            fail("Should have 2 properties, got: "+stuff);
        }
   }
}