This is an automated email from the ASF dual-hosted git repository. kenhuuu pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/tinkerpop.git
commit c2ab68a957227539858b0c14a98a240bbc78cb6d Merge: 405d53bb34 35d8f49b6b Author: Ken Hu <[email protected]> AuthorDate: Mon Aug 31 11:08:04 2026 -0700 Merge branch '3.8-dev' CHANGELOG.asciidoc | 1 + docs/src/reference/gremlin-applications.asciidoc | 1 + docs/src/upgrade/release-3.7.x.asciidoc | 39 ++ .../structure/io/graphson/GraphSONMapper.java | 235 +++++++- .../com/example/gadget/GraphSONTestGadgets.java | 127 +++++ .../io/graphson/GraphSONMapperBuilderTest.java | 40 ++ .../GraphSONMapperPartialEmbeddedTypeTest.java | 595 +++++++++++++++++++++ .../gremlin/structure/io/IoCustomTest.java | 5 +- .../tinkerpop/gremlin/structure/io/IoTest.java | 6 +- 9 files changed, 1035 insertions(+), 14 deletions(-) diff --cc gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java index 53dc23d08d,bab483d1d2..0485a19338 --- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java +++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java @@@ -64,6 -75,49 +76,47 @@@ import java.util.UUID * @author Stephen Mallette (http://stephen.genoprime.com) */ public class GraphSONMapper implements Mapper<ObjectMapper> { + + // Java base value types registered for GraphSON 2.0/3.0, shared by registerJavaBaseTypes and the V1 name set. + private static final List<Class> GRAPHSON_JAVA_BASE_TYPES = Arrays.asList( + UUID.class, Class.class, Calendar.class, Date.class, TimeZone.class, Timestamp.class); + + // concrete runtime class names GraphSON 1.0 writes that registeredV2V3Types() does not supply, as it declares + // only the java.util.List, Map and Set interfaces + private static final List<String> GRAPHSON_1_0_ALLOWED_EXTRA_TYPE_NAMES = Arrays.asList( + "java.lang.Boolean", + "java.lang.Object", + "java.lang.String", + "java.net.URI", + "java.sql.Date", + "java.sql.Time", + "java.time.DayOfWeek", + "java.time.Month", + "java.util.ArrayDeque", + "java.util.ArrayList", + "java.util.Arrays$ArrayList", + "java.util.Collections$EmptyList", + "java.util.Collections$EmptyMap", + "java.util.Collections$EmptySet", + "java.util.Collections$SingletonList", + "java.util.Collections$SingletonMap", + "java.util.Collections$SingletonSet", + "java.util.Collections$UnmodifiableList", + "java.util.Collections$UnmodifiableMap", + "java.util.Collections$UnmodifiableRandomAccessList", + "java.util.Collections$UnmodifiableSet", + "java.util.Currency", + "java.util.GregorianCalendar", + "java.util.HashMap", + "java.util.HashSet", + "java.util.LinkedHashMap", + "java.util.LinkedHashSet", + "java.util.LinkedList", + "java.util.Locale", + "java.util.Properties", + "java.util.TreeMap", - "java.util.TreeSet", - // gremlin-util depends on gremlin-core, so the V1 message envelope is named here without a class reference - "org.apache.tinkerpop.gremlin.util.message.RequestMessage"); ++ "java.util.TreeSet"); + private static final Set<String> GRAPHSON_1_0_ALLOWED_TYPE_NAMES = graphSON1dAllowedTypeNames(); public static final int DEFAULT_MAX_NUMBER_LENGTH = 10000; private final List<SimpleModule> customModules; @@@ -72,7 -126,7 +125,8 @@@ private final GraphSONVersion version; private final TypeInfo typeInfo; private final StreamReadConstraints streamReadConstraints; + private final PDTRegistry pdtRegistry; + final List<String> allowedTypeIdNames; private GraphSONMapper(final Builder builder) { this.customModules = builder.customModules; @@@ -81,7 -135,7 +135,8 @@@ this.version = builder.version; this.streamReadConstraints = builder.streamReadConstraintsBuilder.build(); this.typeInfo = builder.typeInfo; + this.pdtRegistry = builder.pdtRegistry; + this.allowedTypeIdNames = builder.allowedTypeIdNames; } @Override @@@ -199,8 -381,8 +405,9 @@@ builder.loadCustomModules = mapper.loadCustomSerializers; builder.normalize = mapper.normalize; builder.typeInfo = mapper.typeInfo; + builder.pdtRegistry = mapper.pdtRegistry; builder.streamReadConstraintsBuilder = mapper.streamReadConstraints.rebuild(); + builder.allowedTypeIdNames.addAll(mapper.allowedTypeIdNames); return builder; } @@@ -231,7 -407,7 +432,8 @@@ private StreamReadConstraints.Builder streamReadConstraintsBuilder = StreamReadConstraints.builder() .maxNumberLength(DEFAULT_MAX_NUMBER_LENGTH); private TypeInfo typeInfo = null; + private PDTRegistry pdtRegistry = null; + private final List<String> allowedTypeIdNames = new ArrayList<>(); private Builder() { } @@@ -316,15 -492,19 +518,28 @@@ return this; } + /** + * Set the {@link PDTRegistry} to enable automatic hydration of + * {@link org.apache.tinkerpop.gremlin.structure.io.pdt.CompositePDT} values during deserialization. + */ + public Builder pdtRegistry(final PDTRegistry pdtRegistry) { + this.pdtRegistry = pdtRegistry; + return this; + } + + /** + * Adds a fully qualified class name that GraphSON 1.0 embedded-type deserialization resolves in a + * {@code @class} property, in addition to the default names. A name is matched by equality, so it covers the + * class named and its arrays, but not its subclasses or its package. Adding the same name more than once has + * no additional effect. + * <p/> - * Has no effect on GraphSON 2.0 or 3.0, which resolve types through a fixed registry rather than by name. ++ * Has no effect on GraphSON 2.0, 3.0 or 4.0, which resolve types through fixed registries rather than by name. + */ + public Builder addAllowedTypeIdName(final String... names) { + this.allowedTypeIdNames.addAll(Arrays.asList(names)); + return this; + } + public Builder maxNumberLength(final int maxNumLength) { this.streamReadConstraintsBuilder.maxNumberLength(maxNumLength); return this; diff --cc gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapperPartialEmbeddedTypeTest.java index d151ca5065,ea1d216e36..ec552f16c9 --- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapperPartialEmbeddedTypeTest.java +++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapperPartialEmbeddedTypeTest.java @@@ -30,16 -44,27 +42,28 @@@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; + import java.math.BigDecimal; + import java.nio.ByteBuffer; + import java.time.DayOfWeek; import java.time.Instant; +import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.ZonedDateTime; + import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; + import java.util.Collections; + import java.util.Currency; + import java.util.EnumMap; import java.util.HashMap; import java.util.LinkedHashMap; + import java.util.LinkedHashSet; + import java.util.LinkedList; import java.util.List; + import java.util.Locale; import java.util.Map; + import java.util.Set; + import java.util.TreeSet; import java.util.UUID; import static org.hamcrest.MatcherAssert.assertThat; @@@ -222,6 -279,575 +255,568 @@@ public class GraphSONMapperPartialEmbed assertEquals(100L, read.get("test")); } + @Test + public void shouldRejectNetworkPackageTypeWithEmbedTypeSettingV1() { + // a name resolves only by being listed, so java.net.URL does not, even though the sibling + // java.net.InetAddress that GraphSON 2.0/3.0 register does + assertDeniedByTypeValidator(v1Typed(), + "{\"@class\":\"java.util.HashMap\",\"v\":{\"@class\":\"java.net.URL\",\"u\":\"http://example.com\"}}"); + } + + @Test + public void shouldRoundTripArraysWithEmbedTypeSettingV1() throws Exception { + // an array type id is decided by its component name, and a nested array carries one per level. A primitive + // component ("[B") names no class, while an object component ("[Ljava.lang.String;") names one. + final ObjectMapper mapper = v1Typed(); + assertArrayEquals(new byte[]{1, 2, 3}, (byte[]) roundTripInMap(mapper, new byte[]{1, 2, 3})); + assertArrayEquals(new Boolean[]{true, false}, + (Boolean[]) roundTripInMap(mapper, new Boolean[]{true, false})); + assertArrayEquals(new String[]{"a", "b"}, (String[]) roundTripInMap(mapper, new String[]{"a", "b"})); + assertThat(Arrays.deepEquals(new String[][]{{"a"}, {"b"}}, + (String[][]) roundTripInMap(mapper, new String[][]{{"a"}, {"b"}})), is(true)); + } + + @Test + public void shouldRoundTripSqlAndUtilValueTypesWithEmbedTypeSettingV1() throws Exception { + // java.sql.Time is written as its toString, which formats and parses back in the default time zone, so + // valueOf of a fixed literal round-trips anywhere + final ObjectMapper mapper = v1Typed(); + assertRoundTripsInMap(mapper, Arrays.asList( + new java.sql.Timestamp(0L), + java.sql.Time.valueOf("12:34:56"), + Locale.US, + Currency.getInstance("USD"))); + + // java.util.ArrayDeque does not define value equality, so it is compared element-wise + final Object read = roundTripInMap(mapper, new ArrayDeque<>(Arrays.asList("a", "b"))); + assertThat(read, instanceOf(ArrayDeque.class)); + assertEquals(Arrays.asList("a", "b"), new ArrayList<>((ArrayDeque<?>) read)); + } + + @Test + public void shouldRejectArrayOfDisallowedComponentWithEmbedTypeSettingV1() { + // an array type id is decided by its component name, so an unlisted component does not resolve + assertDeniedByTypeValidator(v1Typed(), + "{\"@class\":\"java.util.HashMap\",\"v\":[\"[Ljava.io.File;\",[\"/tmp/x\"]]}"); + } + + @Test + public void shouldAllowConfiguredTypeIdNameWithEmbedTypeSettingV1() throws Exception { + final String json = "{\"@class\":\"java.util.HashMap\",\"p\":{\"@class\":\"com.example.gadget.GraphSONTestGadgets$SamplePojo\",\"x\":42}}"; + + // not among the allowed names by default + assertDeniedByTypeValidator(v1Typed(), json); + + final ObjectMapper mapper = GraphSONMapper.build().version(GraphSONVersion.V1_0).typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName("com.example.gadget.GraphSONTestGadgets$SamplePojo").create().createMapper(); + final Map read = mapper.readValue(json, HashMap.class); + assertEquals(new SamplePojo(42), read.get("p")); + } + + @Test + public void shouldAllowConfiguredTypeIdNameInsideCollectionWithEmbedTypeSettingV1() throws Exception { + final ObjectMapper mapper = GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName(SamplePojo.class.getName()) + .create().createMapper(); + + final Object read = roundTripInMap(mapper, Collections.singletonList(new SamplePojo(42))); + assertEquals(Collections.singletonList(new SamplePojo(42)), read); + assertEquals(SamplePojo.class, ((List<?>) read).get(0).getClass()); + } + + @Test + public void shouldAllowArrayOfConfiguredTypeIdNameButRejectSubclassWithEmbedTypeSettingV1() throws Exception { + final ObjectMapper mapper = GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName(SamplePojo.class.getName()) + .create().createMapper(); + + assertArrayEquals(new SamplePojo[]{new SamplePojo(42)}, + (SamplePojo[]) roundTripInMap(mapper, new SamplePojo[]{new SamplePojo(42)})); + + final String subclassTypeId = SamplePojoSubclass.class.getName(); + assertTypeIdDeniedByTypeValidator(mapper, + "{\"@class\":\"java.util.HashMap\",\"p\":{\"@class\":\"" + subclassTypeId + "\",\"x\":42}}", + subclassTypeId); + } + + @Test + public void shouldRejectDisallowedCollectionElementWithoutInitializingClassV1() { + final String typeId = StaticInitCanaryElement.class.getName(); + final String json = "{\"@class\":\"java.util.HashMap\",\"v\":[\"java.util.ArrayList\",[" + + "{\"@class\":\"" + typeId + "\",\"x\":1}]]}"; + + System.clearProperty(StaticInitCanaryElement.FIRED_PROPERTY); + assertTypeIdDeniedByTypeValidator(v1Typed(), json, typeId); + assertNull("type id resolution must not initialize the class named by a refused collection element", + System.getProperty(StaticInitCanaryElement.FIRED_PROPERTY)); + } + + @Test + public void shouldRejectEnumTypeParameterAndNotLoadItV1() { + System.clearProperty(StaticInitCanaryEnum.FIRED_PROPERTY); + assertDeniedByTypeValidator(v1Typed(), + "{\"@class\":\"java.util.HashMap<com.example.gadget.GraphSONTestGadgets$StaticInitCanaryEnum,java.lang.String>\",\"A\":\"v\"}"); + assertNull("type id resolution must not load the enum named as a type argument", + System.getProperty(StaticInitCanaryEnum.FIRED_PROPERTY)); + } + + @Test + public void shouldRejectClassValueAndNotLoadItV1() { + // java.lang.Class is held out of the derived names by graphSON1dDerivedTypeNames() + System.clearProperty(StaticInitCanaryValue.FIRED_PROPERTY); + assertDeniedByTypeValidator(v1Typed(), + "{\"@class\":\"java.util.HashMap\",\"c\":[\"java.lang.Class\",\"com.example.gadget.GraphSONTestGadgets$StaticInitCanaryValue\"]}"); + assertNull("type id resolution must not load the class named by a java.lang.Class value", + System.getProperty(StaticInitCanaryValue.FIRED_PROPERTY)); + } + + @Test + public void shouldNotLoadDisallowedClassWhenRefusingV1() { + // a @class outside the allowed names is decided from the name alone + System.clearProperty(StaticInitCanary.FIRED_PROPERTY); + assertDeniedByTypeValidator(v1Typed(), + "{\"@class\":\"java.util.HashMap\",\"g\":{\"@class\":\"com.example.gadget.GraphSONTestGadgets$StaticInitCanary\",\"x\":1}}"); + assertNull("type id resolution must not load the class named by a refused @class", + System.getProperty(StaticInitCanary.FIRED_PROPERTY)); + } + + @Test + public void shouldRoundTripInetAddressWithEmbedTypeSettingV1() throws Exception { + // java.net.InetAddress is a derived name, since GraphSON 2.0/3.0 register it + final ObjectMapper mapper = v1Typed(); + final Map<String, Object> m = new HashMap<>(); + m.put("a", java.net.InetAddress.getByAddress(new byte[]{127, 0, 0, 1})); + + final Map read = mapper.readValue(mapper.writeValueAsString(m), HashMap.class); + assertEquals(java.net.InetAddress.getByAddress(new byte[]{127, 0, 0, 1}), read.get("a")); + } + + @Test + public void shouldRoundTripUriWithEmbedTypeSettingV1() throws Exception { + // java.net.URI is listed rather than derived, as GraphSON 2.0/3.0 do not register it + final ObjectMapper mapper = v1Typed(); + final Map<String, Object> m = new HashMap<>(); + m.put("u", new java.net.URI("http://example.com/x")); + + final Map read = mapper.readValue(mapper.writeValueAsString(m), HashMap.class); + assertEquals(new java.net.URI("http://example.com/x"), read.get("u")); + } + + @Test + public void shouldRoundTripBoxedPrimitivesWithEmbedTypeSettingV1() throws Exception { + // String, Integer, Double and Boolean are written bare, so only the boxed types JSON cannot represent + // natively carry a type id + assertRoundTripsInMap(v1Typed(), Arrays.asList( + Character.valueOf('c'), + BigDecimal.ONE)); + } + + @Test + public void shouldRoundTripCollectionTypesWithEmbedTypeSettingV1() throws Exception { + // the concrete collection class names GraphSON 1.0 writes for a Map-nested value. Each case checks the type + // id in the text written as well as the class read back, since AbstractMap.equals and AbstractList.equals + // are structural and would keep passing with no type id written at all. + final ObjectMapper mapper = v1Typed(); + final Map<String, Object> entry = Collections.singletonMap("a", "b"); + final List<String> one = Collections.singletonList("a"); + + assertTypedRoundTripInMap(mapper, new LinkedHashMap<>(entry)); + assertTypedRoundTripInMap(mapper, new LinkedHashSet<>(one)); + + // Jackson cannot rebuild either class named, so it reads the type id back through a stand-in it can + // construct: Arrays.asList comes back as a plain ArrayList, and either unmodifiable list name comes back as a + // wrapper around an ArrayList. unmodifiableList writes Collections$UnmodifiableList over a LinkedList but + // Collections$UnmodifiableRandomAccessList over an ArrayList, so the allowed names carry both. + assertTypedRoundTripInMap(mapper, Arrays.asList("a", "b"), ArrayList.class); + assertTypedRoundTripInMap(mapper, Collections.unmodifiableList(new LinkedList<>(one)), + Collections.unmodifiableList(new ArrayList<>(one)).getClass()); + + // a Map in a List in a Map, so a type id is resolved at every depth + final Map<String, Object> nested = new HashMap<>(); + nested.put("inner", new ArrayList<>(Collections.singletonList(new HashMap<String, Object>(entry)))); + final String nestedJson = writeInMap(mapper, nested); + assertThat(nestedJson, containsString("\"inner\":[\"java.util.ArrayList\",[")); + + final Object readNested = readMapValue(mapper, nestedJson); + assertEquals(nested, readNested); + assertEquals(HashMap.class, readNested.getClass()); + final Object readInner = ((Map<?, ?>) readNested).get("inner"); + assertEquals(ArrayList.class, readInner.getClass()); + assertEquals(HashMap.class, ((List<?>) readInner).get(0).getClass()); + } + + @Test + public void shouldRoundTripEnumTypesWithEmbedTypeSettingV1() throws Exception { + // T uses per-constant subclasses, whose type id is still the declaring enum. DayOfWeek is listed in + // GRAPHSON_1_0_ALLOWED_EXTRA_TYPE_NAMES rather than derived from the GraphSON 2.0/3.0 registry. + assertRoundTripsInMap(v1Typed(), Arrays.asList( + Direction.OUT, + T.id, + DayOfWeek.MONDAY)); + } + + @Test + public void shouldReadAllowedBaseTypeIdsWithEmbedTypeSettingV1() throws Exception { + // allowed names GraphSON 1.0 does not write itself, since it writes the concrete runtime class instead. A + // document may still name them, and what comes back is the concrete type Jackson picks for the base type. + final ObjectMapper mapper = v1Typed(); + + assertEquals(Collections.singletonMap("a", "b"), + readMapValue(mapper, "{\"@class\":\"java.util.HashMap\",\"v\":{\"@class\":\"java.util.Map\",\"a\":\"b\"}}")); + assertEquals(ByteBuffer.wrap(new byte[]{1, 2}), + readMapValue(mapper, "{\"@class\":\"java.util.HashMap\",\"v\":[\"java.nio.ByteBuffer\",\"AQI=\"]}")); + } + + @Test + public void shouldRefuseStarGraphBecauseV1WritesItsStarVertexAsAMapV1() throws Exception { + // GraphSON 1.0 writes a StarGraph as a bean whose "starVertex" property carries the type id + // java.util.HashMap rather than a StarVertex, so Jackson cannot rebuild a StarGraph from what GraphSON 1.0 + // writes and the name is not among the allowed names. + final ObjectMapper mapper = v1Typed(); + final String json; + try (final StarGraph starGraph = StarGraph.open()) { + starGraph.addVertex("label", "person"); + json = writeInMap(mapper, starGraph); + } + + assertThat(json, containsString("\"" + GraphSONTokens.CLASS + "\":\"" + StarGraph.class.getName() + "\"")); + assertThat(json, containsString("\"starVertex\":{\"" + GraphSONTokens.CLASS + "\":\"java.util.HashMap\"")); + assertTypeIdDeniedByTypeValidator(mapper, json, StarGraph.class.getName()); + } + + @Test + public void shouldRefuseByteBufferBecauseV1WritesItsConcreteHeapClassV1() throws Exception { + // java.nio.ByteBuffer is a derived name and does resolve (see + // shouldReadAllowedBaseTypeIdsWithEmbedTypeSettingV1), but what GraphSON 1.0 writes for wrap() or allocate() + // is the concrete java.nio.HeapByteBuffer, which the allowed names do not carry, so V1 cannot read what it + // writes. + final ObjectMapper mapper = v1Typed(); + + final String json = writeInMap(mapper, ByteBuffer.wrap(new byte[]{1, 2})); + assertThat(json, containsString("[\"java.nio.HeapByteBuffer\",")); + assertTypeIdDeniedByTypeValidator(mapper, json, "java.nio.HeapByteBuffer"); + } + + @Test + public void shouldRefuseEnumMapBecauseV1WritesAParameterizedTypeIdV1() throws Exception { + // GraphSON 1.0 writes an EnumMap as the parameterized type id "java.util.EnumMap<...,...>", which + // GraphSON1dScreeningIdResolver refuses, so an EnumMap no longer reads back. That is a round-trip regression + // introduced by this change. An EnumSet is written as a parameterized type id too, but it did not read back + // beforehand either, as jackson-databind#4849 leaves the type id EnumSet writes unresolvable. + final ObjectMapper mapper = v1Typed(); + final String typeId = "java.util.EnumMap<" + Direction.class.getName() + ",java.lang.Object>"; + + final EnumMap<Direction, String> value = new EnumMap<>(Direction.class); + value.put(Direction.OUT, "x"); + + final String json = writeInMap(mapper, value); + assertThat(json, containsString("\"" + GraphSONTokens.CLASS + "\":\"" + typeId + "\"")); + assertParameterizedTypeIdRefused(mapper, json, typeId); + } + + @Test + public void shouldRejectUnresolvableHostnameWithoutLookupWithEmbedTypeSettingV1() { + // java.net.InetAddress is among the allowed names, so a document may name it, but a value that is not an IP + // address literal is refused on its syntax rather than looked up. The message is the evidence of that, as a + // lookup would report an UnknownHostException instead. It is Jackson's own wording, so an upgrade can move it. + final String json = "{\"@class\":\"java.util.HashMap\",\"v\":" + + "[\"java.net.InetAddress\",\"this-name-should-not-resolve.invalid\"]}"; + try { + v1Typed().readValue(json, HashMap.class); + fail("an InetAddress value that is not an IP address literal must be refused"); + } catch (Exception e) { + assertThat(e, instanceOf(InvalidFormatException.class)); + assertThat(e.getMessage(), containsString("Not a valid IP address string literal")); + } + } + + @Test + public void shouldAllowConfiguredClassValueWithEmbedTypeSettingV1() throws Exception { + // java.lang.Class is held out of the derived names, and no separate list holds it out permanently, so a + // caller that wants GraphSON 1.0 to read one names it like any other name + final String json = "{\"@class\":\"java.util.HashMap\",\"c\":[\"java.lang.Class\",\"java.lang.String\"]}"; + assertDeniedByTypeValidator(v1Typed(), json); + + final ObjectMapper mapper = GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES).addAllowedTypeIdName("java.lang.Class").create().createMapper(); + assertEquals(String.class, mapper.readValue(json, HashMap.class).get("c")); + } + + @Test + public void shouldAllowSeveralConfiguredTypeIdNamesWithEmbedTypeSettingV1() throws Exception { + // addAllowedTypeIdName takes several names at once, and successive calls add to earlier ones rather than + // replacing them + final String json = "{\"@class\":\"java.util.HashMap\"," + + "\"p\":{\"@class\":\"com.example.gadget.GraphSONTestGadgets$SamplePojo\",\"x\":42}," + + "\"u\":[\"java.net.URL\",\"http://example.com/x\"]}"; + + assertDeniedByTypeValidator(v1Typed(), json); + + assertConfiguredNamesResolve(GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName(SamplePojo.class.getName(), "java.net.URL") + .create().createMapper(), json); + + assertConfiguredNamesResolve(GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName(SamplePojo.class.getName()) + .addAllowedTypeIdName("java.net.URL") + .create().createMapper(), json); + } + + @Test + public void shouldIgnoreDuplicateConfiguredTypeIdNamesWithEmbedTypeSettingV1() throws Exception { + final String json = "{\"@class\":\"java.util.HashMap\"," + + "\"p\":{\"@class\":\"com.example.gadget.GraphSONTestGadgets$SamplePojo\",\"x\":42}}"; + + final ObjectMapper mapper = GraphSONMapper.build().version(GraphSONVersion.V1_0) + .typeInfo(TypeInfo.PARTIAL_TYPES) + .addAllowedTypeIdName(SamplePojo.class.getName()) + .addAllowedTypeIdName(SamplePojo.class.getName()) + .create().createMapper(); + + assertEquals(new SamplePojo(42), mapper.readValue(json, HashMap.class).get("p")); + } + + @Test + public void shouldDeriveTypeIdNamesFromTheRegisteredGraphSON2And3TypesV1() { + // a new put(...) in GraphSONModuleV2, GraphSONModuleV3, GraphSONXModuleV2 or GraphSONXModuleV3 widens what + // GraphSON 1.0 reads as a side effect, which pinning the derived names makes visible. + // + // The set is classpath dependent: GraphSONModule.tryLoadSparqlStrategy() contributes SparqlStrategy when + // sparql-gremlin is present, which it is not on the gremlin-core test classpath. A new name that is genuinely + // wanted belongs in the expected set below. + final Set<String> expected = new TreeSet<>(Arrays.asList( + "java.lang.Byte", + "java.lang.Character", + "java.lang.Double", + "java.lang.Float", + "java.lang.Integer", + "java.lang.Long", + "java.lang.Short", + "java.math.BigDecimal", + "java.math.BigInteger", + "java.net.InetAddress", + "java.nio.ByteBuffer", + "java.sql.Timestamp", + "java.time.Duration", + "java.time.Instant", + "java.time.LocalDate", + "java.time.LocalDateTime", + "java.time.LocalTime", + "java.time.MonthDay", + "java.time.OffsetDateTime", + "java.time.OffsetTime", + "java.time.Period", + "java.time.Year", + "java.time.YearMonth", + "java.time.ZoneOffset", + "java.time.ZonedDateTime", + "java.util.Calendar", + "java.util.Date", + "java.util.List", + "java.util.Map", + "java.util.Set", + "java.util.TimeZone", + "java.util.UUID", + "org.apache.tinkerpop.gremlin.process.computer.traversal.strategy.decoration.VertexProgramStrategy", - "org.apache.tinkerpop.gremlin.process.computer.traversal.strategy.finalization.ComputerFinalizationStrategy", + "org.apache.tinkerpop.gremlin.process.computer.traversal.strategy.optimization.GraphFilterStrategy", - "org.apache.tinkerpop.gremlin.process.computer.traversal.strategy.optimization.MessagePassingReductionStrategy", - "org.apache.tinkerpop.gremlin.process.computer.traversal.strategy.verification.VertexProgramRestrictionStrategy", - "org.apache.tinkerpop.gremlin.process.traversal.Bytecode", - "org.apache.tinkerpop.gremlin.process.traversal.Bytecode$Binding", + "org.apache.tinkerpop.gremlin.process.traversal.DT", + "org.apache.tinkerpop.gremlin.process.traversal.GType", + "org.apache.tinkerpop.gremlin.process.traversal.Merge", + "org.apache.tinkerpop.gremlin.process.traversal.NotP", + "org.apache.tinkerpop.gremlin.process.traversal.Operator", + "org.apache.tinkerpop.gremlin.process.traversal.Order", + "org.apache.tinkerpop.gremlin.process.traversal.P", + "org.apache.tinkerpop.gremlin.process.traversal.Path", + "org.apache.tinkerpop.gremlin.process.traversal.Pick", + "org.apache.tinkerpop.gremlin.process.traversal.Pop", + "org.apache.tinkerpop.gremlin.process.traversal.SackFunctions$Barrier", + "org.apache.tinkerpop.gremlin.process.traversal.Scope", + "org.apache.tinkerpop.gremlin.process.traversal.TextP", + "org.apache.tinkerpop.gremlin.process.traversal.Traverser", + "org.apache.tinkerpop.gremlin.process.traversal.step.util.BulkSet", + "org.apache.tinkerpop.gremlin.process.traversal.step.util.Tree", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.TraversalStrategyProxy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.ConnectiveStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.ElementIdStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.EventStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.HaltedTraverserStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.OptionsStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.PartitionStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.SeedStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.SubgraphStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.finalization.MatchAlgorithmStrategy", - "org.apache.tinkerpop.gremlin.process.traversal.strategy.finalization.ProfileStrategy", - "org.apache.tinkerpop.gremlin.process.traversal.strategy.finalization.ReferenceElementStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.AdjacentToIncidentStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.ByModulatorOptimizationStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.CountStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.EarlyLimitStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.FilterRankingStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.IdentityRemovalStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.IncidentToAdjacentStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.InlineFilterStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.LazyBarrierStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.MatchPredicateStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.OrderLimitStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.PathProcessorStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.PathRetractionStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.ProductiveByStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.optimization.RepeatUnrollStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.ComputerVerificationStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.EdgeLabelVerificationStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.LambdaRestrictionStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.ReadOnlyStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.ReservedKeysVerificationStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.StandardVerificationStrategy", + "org.apache.tinkerpop.gremlin.process.traversal.util.AndP", + "org.apache.tinkerpop.gremlin.process.traversal.util.Metrics", + "org.apache.tinkerpop.gremlin.process.traversal.util.OrP", + "org.apache.tinkerpop.gremlin.process.traversal.util.TraversalExplanation", + "org.apache.tinkerpop.gremlin.process.traversal.util.TraversalMetrics", + "org.apache.tinkerpop.gremlin.structure.Column", + "org.apache.tinkerpop.gremlin.structure.Direction", + "org.apache.tinkerpop.gremlin.structure.Edge", + "org.apache.tinkerpop.gremlin.structure.Property", + "org.apache.tinkerpop.gremlin.structure.T", + "org.apache.tinkerpop.gremlin.structure.Vertex", + "org.apache.tinkerpop.gremlin.structure.VertexProperty", + "org.apache.tinkerpop.gremlin.structure.VertexProperty$Cardinality", + "org.apache.tinkerpop.gremlin.util.function.Lambda")); + + final Set<String> derived = new TreeSet<>(GraphSONMapper.graphSON1dDerivedTypeNames()); + final Set<String> added = new TreeSet<>(derived); + added.removeAll(expected); + final Set<String> dropped = new TreeSet<>(expected); + dropped.removeAll(derived); + assertEquals("derived but not expected: " + added + "; expected but not derived: " + dropped, + expected, derived); + } + + private static ObjectMapper v1Typed() { + return GraphSONMapper.build().version(GraphSONVersion.V1_0).typeInfo(TypeInfo.PARTIAL_TYPES).create().createMapper(); + } + + /** + * Writes a value as a {@code Map} value and reads it back. That is the shape real payloads use, and it exercises + * the untyped {@code Object} value path rather than a declared concrete class. + */ + private static Object roundTripInMap(final ObjectMapper mapper, final Object value) throws Exception { + return readMapValue(mapper, writeInMap(mapper, value)); + } + + /** + * Writes a value as a {@code Map} value and returns the text, so the type id GraphSON 1.0 emitted for it can be + * asserted on directly. + */ + private static String writeInMap(final ObjectMapper mapper, final Object value) throws Exception { + final Map<String, Object> m = new HashMap<>(); + m.put(MAP_VALUE_KEY, value); + return mapper.writeValueAsString(m); + } + + private static void assertRoundTripsInMap(final ObjectMapper mapper, final List<Object> values) throws Exception { + for (final Object value : values) { + assertEquals(value.getClass().getName(), value, roundTripInMap(mapper, value)); + } + } + + private static void assertTypedRoundTripInMap(final ObjectMapper mapper, final Object value) throws Exception { + assertTypedRoundTripInMap(mapper, value, value.getClass()); + } + + /** + * Asserts that a {@code Map} or {@code Collection} value reads back equal and as {@code expectedClass}, and that + * the type id GraphSON 1.0 wrote for it is in the text written. Equality alone is no evidence of typing, as an + * equal value of any other concrete {@code Map} or {@code List} class satisfies it. {@code expectedClass} is what + * Jackson rebuilds for the type id written, which is not always the class that was written. + */ + private static void assertTypedRoundTripInMap(final ObjectMapper mapper, final Object value, + final Class<?> expectedClass) throws Exception { + final String name = value.getClass().getName(); + final String json = writeInMap(mapper, value); + assertThat("no type id written for " + name + " in " + json, json, containsString(typeIdInMapOf(value))); + + final Object read = readMapValue(mapper, json); + assertEquals(name, value, read); + assertEquals("concrete class read back for " + name, expectedClass, read.getClass()); + } + + /** + * The text GraphSON 1.0 writes for the type id of a {@code Map} or {@code Collection} held as a {@code Map} + * value: a {@code Map} carries it as an {@code "@class"} field of its own object, a {@code Collection} as the + * first element of a wrapper array. Both are anchored to the key the value sits under, so neither can be + * satisfied by the type id of the enclosing {@code Map}. + */ + private static String typeIdInMapOf(final Object value) { + final String prefix = "\"" + MAP_VALUE_KEY + "\":"; + final String name = value.getClass().getName(); + return value instanceof Map + ? prefix + "{\"" + GraphSONTokens.CLASS + "\":\"" + name + "\"" + : prefix + "[\"" + name + "\","; + } + + private static Object readMapValue(final ObjectMapper mapper, final String json) throws Exception { + return mapper.readValue(json, HashMap.class).get(MAP_VALUE_KEY); + } + + /** + * Asserts that both configured names resolved. A {@code java.net.URL} is compared as text, since + * {@code URL.equals} can consult the network. + */ + private static void assertConfiguredNamesResolve(final ObjectMapper mapper, final String json) throws Exception { + final Map read = mapper.readValue(json, HashMap.class); + assertEquals(new SamplePojo(42), read.get("p")); + assertEquals("http://example.com/x", read.get("u").toString()); + } + + private static void assertDeniedByTypeValidator(final ObjectMapper mapper, final String json) { + try { + mapper.readValue(json, HashMap.class); + fail("a @class outside the allowed names must not resolve"); + } catch (InvalidTypeIdException expected) { + } catch (Exception other) { + throw new AssertionError("expected InvalidTypeIdException, got " + other, other); + } + } + + /** + * Asserts that {@code typeId} in particular is what the read was refused on, and that the allowed names are what + * refused it. A listed type id that names no loadable class is also an {@code InvalidTypeIdException}, reported as + * "no such class found" rather than as a denial. Both strings matched are Jackson's own wording, so a Jackson + * upgrade can move them. + */ + private static void assertTypeIdDeniedByTypeValidator(final ObjectMapper mapper, final String json, + final String typeId) { + try { + mapper.readValue(json, HashMap.class); + fail("resolution of the type id " + typeId + " must be refused"); + } catch (InvalidTypeIdException expected) { + assertThat(expected.getMessage(), containsString("Could not resolve type id '" + typeId + "'")); + assertThat(expected.getMessage(), containsString("denied resolution")); + } catch (Exception other) { + throw new AssertionError("expected InvalidTypeIdException, got " + other, other); + } + } + + /** + * Asserts that {@code typeId} was refused for being parameterized, which {@code GraphSON1dScreeningIdResolver} + * does before the allowed names are consulted at all. + */ + private static void assertParameterizedTypeIdRefused(final ObjectMapper mapper, final String json, + final String typeId) { + try { + mapper.readValue(json, HashMap.class); + fail("a parameterized type id must be refused: " + typeId); + } catch (InvalidTypeIdException expected) { + assertThat(expected.getMessage(), containsString("Could not resolve type id '" + typeId + "'")); + assertThat(expected.getMessage(), containsString("GraphSON 1.0 does not permit a parameterized type id")); + } catch (Exception other) { + throw new AssertionError("expected InvalidTypeIdException, got " + other, other); + } + } + @Test public void shouldNotHandleMapWithTypesUsingEmbedTypeSettingV1() throws Exception { final ObjectMapper mapper = GraphSONMapper.build()
