diff --git a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java index 703d7a4a8c..f2ea59f9eb 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java +++ b/agentscope-core/src/main/java/io/agentscope/core/util/JsonSchemaUtils.java @@ -27,8 +27,17 @@ import com.github.victools.jsonschema.module.jackson.JacksonModule; import com.github.victools.jsonschema.module.jackson.JacksonOption; import io.agentscope.core.tool.ToolSchemaModule; +import java.lang.reflect.Executable; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.GenericDeclaration; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; /** * Utility class for JSON Schema operations. @@ -53,9 +62,37 @@ *
  • {@code @JsonClassDescription(...)} - add class description
  • * * - *

    All public methods are thread-safe. Schema generation through the shared victools - * {@code SchemaGenerator} is serialized by an internal lock, because the generator itself - * is not designed for concurrent use.

    + *

    All public methods are thread-safe. Generated schemas are cached per {@link Class} / + * {@link Type}; the shared victools {@code SchemaGenerator} is not designed for concurrent use, + * so the internal lock is taken only on a cache miss (the first time a given class or type is + * seen). A cache hit converts a fresh, independently mutable {@code Map} from the cached, + * never-mutated {@link JsonNode}, so it needs no lock.

    + * + *

    Cache entries are scoped to the class they describe instead of living in a static map keyed + * by {@code Class}, so an entry cannot outlive that class or pin the classloader that defined it. + * That matters because the structured-output and tool-parameter classes reaching this utility are + * not always compile-time-fixed: extensions can load skills and tools at runtime, and + * multi-tenant deployments may load classes per tenant.

    + * + *

    A parameterized type is scoped to the deepest application class it mentions rather than to + * its raw class: {@code List} is held on {@code TenantDto} and dies with it. Scoping + * such an entry to {@code java.util.List} instead would park it, and the {@code TenantDto} its key + * references, on a class the JVM never unloads, which is the same classloader pinning in a + * different place. See {@link #scopeClassOf} for how the class is picked.

    + * + *

    The number of entries a single class's cache can hold is bounded by the distinct generic + * signatures the code produces against it: every {@link Type} reaching this utility originates in + * a {@link TypeReference} literal or a reflective method signature, and two structurally equal + * signatures share one entry. Loading a class at runtime therefore adds a class with its own + * cache rather than another entry on an existing one. A caller that synthesizes {@code Type} + * instances at runtime can add entries beyond that bound, but those entries are released together + * with the class that owns the cache, which is the first application class the type mentions when + * it mentions more than one.

    + * + *

    One case is deliberately outside that bound: a signature built only from JDK classes, such as + * {@code List}, has no application class to be scoped to, so it stays on its raw class + * ({@code java.util.List}), which the JVM never unloads. Those entries live as long as the JVM and + * cannot pin an application class, because no application class appears in them.

    * * @hidden */ @@ -68,10 +105,55 @@ public class JsonSchemaUtils { /** * Guards the shared victools {@link SchemaGenerator}, which is not thread-safe: its * JacksonModule keeps an unsynchronized introspection cache, so concurrent schema - * generation must be serialized. + * generation must be serialized. Only cache misses in {@link #CLASS_SCHEMA_SLOT} and + * {@link #TYPE_SCHEMA_SLOT} take this lock; cache hits never do. */ private static final Object SCHEMA_LOCK = new Object(); + /** + * Schema cache slot of each class. A schema is a deterministic function of the class and the + * static, never-changing generator config, so entries never need invalidation. Cached nodes + * are never mutated after being stored: every call still converts a fresh, independently + * mutable {@link Map} from the cached node, so callers that mutate the returned map (e.g. + * {@code ToolSchemaGenerator}) cannot corrupt the cache or interfere with one another. + * + *

    Creating the slot through {@link ClassValue} holds it on the class it describes, rather + * than in a static map that strongly references the class as a key, so a slot cannot keep that + * class — or the classloader which defined it — reachable once the rest of the application has + * let go of them. + */ + private static final ClassValue> CLASS_SCHEMA_SLOT = + new ClassValue<>() { + @Override + protected AtomicReference computeValue(Class clazz) { + return new AtomicReference<>(); + } + }; + + /** + * Schema cache slot of each class, keyed by generic {@link Type} to support parameterized + * structured-output and tool-parameter types. The variants of one class (the type variables it + * declares, and every signature mentioning it) share the map held on that class, so these slots + * are scoped to a classloader in the same way as {@link #CLASS_SCHEMA_SLOT}. {@link + * #scopeClassOf} picks that class: the deepest application class the type mentions, which keeps + * {@code List} on {@code TenantDto} instead of on {@code java.util.List}. + * + *

    The map grows with the distinct generic signatures the code writes against that class, + * not with anything a caller supplies at runtime, so it carries the same bound the previous + * static {@code Map} relied on. See the class javadoc for the full argument, + * including the signatures that stay on a JDK class because they mention no other. + * + *

    Because unrelated classes never share a map, a miss takes {@link #SCHEMA_LOCK} without + * grouping unrelated types behind the same lock. + */ + private static final ClassValue> TYPE_SCHEMA_SLOT = + new ClassValue<>() { + @Override + protected Map computeValue(Class scopeClass) { + return new ConcurrentHashMap<>(); + } + }; + static { // JacksonModule to support @JsonProperty, @JsonPropertyDescription annotations JacksonModule jacksonModule = @@ -106,10 +188,7 @@ public class JsonSchemaUtils { */ public static Map generateSchemaFromClass(Class clazz) { try { - JsonNode schemaNode; - synchronized (SCHEMA_LOCK) { - schemaNode = schemaGenerator.generateSchema(clazz); - } + JsonNode schemaNode = cachedSchemaNode(clazz); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -139,15 +218,20 @@ public static Map generateSchemaFromJsonNode(JsonNode schema) { /** * Generate JSON Schema from a Java Type (supports Generics). * + *

    A bare {@link Class} is delegated to {@link #generateSchemaFromClass(Class)}, so a class + * reached through either entry point is generated once and stored in one slot. + * * @param type The type to generate schema for * @return JSON Schema as a Map + * @throws NullPointerException if the type is null */ public static Map generateSchemaFromType(Type type) { + Objects.requireNonNull(type, "type"); + if (type instanceof Class clazz) { + return generateSchemaFromClass(clazz); + } try { - JsonNode schemaNode; - synchronized (SCHEMA_LOCK) { - schemaNode = schemaGenerator.generateSchema(type); - } + JsonNode schemaNode = cachedSchemaNode(type); return JsonUtils.getJsonCodec() .convertValue(schemaNode, new TypeReference>() {}); } catch (Exception e) { @@ -156,6 +240,166 @@ public static Map generateSchemaFromType(Type type) { } } + /** + * Returns the cached schema node for a class, generating it under {@link #SCHEMA_LOCK} on a + * miss. The slot is re-read inside the lock so that a class several threads reach at once is + * still generated exactly once. + */ + private static JsonNode cachedSchemaNode(Class clazz) { + AtomicReference slot = CLASS_SCHEMA_SLOT.get(clazz); + JsonNode cached = slot.get(); + if (cached != null) { + return cached; + } + + synchronized (SCHEMA_LOCK) { + cached = slot.get(); + if (cached == null) { + cached = schemaGenerator.generateSchema(clazz); + slot.set(cached); + } + } + return cached; + } + + /** + * Returns the cached schema node for a type, generating it under {@link #SCHEMA_LOCK} on a + * miss. + * + *

    The miss path re-reads the slot inside the lock rather than calling {@code + * ConcurrentHashMap#computeIfAbsent}, which runs its mapping function while holding the map's + * bin lock: nesting the schema lock inside it would block unrelated types hashing to the same + * bin for the whole generation, and would break the contract that a mapping function must not + * modify its own map. + */ + private static JsonNode cachedSchemaNode(Type type) { + Class scope = scopeClassOf(type); + if (scope == null) { + // No class to hang a slot on, so this type cannot be cached. Every type a method + // signature can declare is attributed, so this path is only reachable for a type a + // caller synthesizes: a wildcard is only ever a type argument, never a parameter or + // return type. + synchronized (SCHEMA_LOCK) { + return schemaGenerator.generateSchema(type); + } + } + + Map slot = TYPE_SCHEMA_SLOT.get(scope); + JsonNode cached = slot.get(type); + if (cached != null) { + return cached; + } + + synchronized (SCHEMA_LOCK) { + cached = slot.get(type); + if (cached == null) { + cached = schemaGenerator.generateSchema(type); + slot.put(type, cached); + } + } + return cached; + } + + /** + * Returns the class whose cache a type's schema belongs to, or {@code null} when the type + * cannot be attributed to one. + * + *

    A parameterized type belongs to the deepest application class it mentions, a type argument + * in preference to the raw type, so {@code List} is held on {@code TenantDto}. Its + * raw class is the fallback for a signature built exclusively from JDK classes, such as {@code + * List}: that entry is permanent, but it cannot pin an application class because none + * appears in it. See the class javadoc for the full argument. + * + *

    The types with no raw class belong to the class that declares them: a type variable to its + * declaring class or to the class declaring its constructor or method, and a generic array to + * whichever class its component type resolves to. Caching those under a class rather than in a + * static map keyed by the type itself keeps the no-classloader-pinning property of {@link + * #CLASS_SCHEMA_SLOT}, since a type variable holds its declaration and would otherwise pin it. + * + * @param type the type to attribute; must not be {@code null} + * @return the class to cache the schema under, or {@code null} if the type has none + */ + private static Class scopeClassOf(Type type) { + if (type instanceof Class clazz) { + return clazz; + } + if (type instanceof ParameterizedType parameterizedType) { + Class applicationClass = applicationClassIn(parameterizedType); + if (applicationClass != null) { + return applicationClass; + } + return parameterizedType.getRawType() instanceof Class rawClass ? rawClass : null; + } + if (type instanceof GenericArrayType arrayType) { + return scopeClassOf(arrayType.getGenericComponentType()); + } + if (type instanceof TypeVariable typeVariable) { + return declaringClassOf(typeVariable); + } + return null; + } + + /** + * Returns the deepest class loaded by an application classloader that the given type mentions, + * or {@code null} when every class it mentions comes from the bootstrap loader. Only a class + * that can itself be unloaded may scope a cache entry: a slot on {@code java.util.List} is never + * released, so an entry there would keep the application classes named by its key reachable for + * the lifetime of the JVM. + */ + private static Class applicationClassIn(Type type) { + if (type instanceof Class clazz) { + return clazz.getClassLoader() == null ? null : clazz; + } + if (type instanceof ParameterizedType parameterizedType) { + for (Type argument : parameterizedType.getActualTypeArguments()) { + Class argumentClass = applicationClassIn(argument); + if (argumentClass != null) { + return argumentClass; + } + } + return applicationClassIn(parameterizedType.getRawType()); + } + if (type instanceof GenericArrayType arrayType) { + return applicationClassIn(arrayType.getGenericComponentType()); + } + if (type instanceof WildcardType wildcardType) { + for (Type bound : wildcardType.getUpperBounds()) { + Class boundClass = applicationClassIn(bound); + if (boundClass != null) { + return boundClass; + } + } + for (Type bound : wildcardType.getLowerBounds()) { + Class boundClass = applicationClassIn(bound); + if (boundClass != null) { + return boundClass; + } + } + return null; + } + if (type instanceof TypeVariable typeVariable) { + Class declaringClass = declaringClassOf(typeVariable); + return declaringClass == null ? null : applicationClassIn(declaringClass); + } + return null; + } + + /** + * Returns the class that declares the given type variable, or {@code null} when the declaration + * is neither a class, a method nor a constructor. A constructor declares type variables just as + * a method does, and both are {@link Executable} rather than {@code Method}. + */ + private static Class declaringClassOf(TypeVariable typeVariable) { + GenericDeclaration declaration = typeVariable.getGenericDeclaration(); + if (declaration instanceof Class declaringClass) { + return declaringClass; + } + if (declaration instanceof Executable executable) { + return executable.getDeclaringClass(); + } + return null; + } + /** * Convert Map to typed object. * diff --git a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java index 0d62c2f107..ba021c060b 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/util/JsonSchemaUtilsTest.java @@ -17,11 +17,16 @@ package io.agentscope.core.util; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -31,6 +36,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntFunction; import org.junit.jupiter.api.Test; @@ -51,6 +57,15 @@ static class NestedModel { public List tags; } + /** Holder for the type shapes a method signature can declare but no class does. */ + static class GenericHolder { + public T[] values; + + public R identity(R input) { + return input; + } + } + @Test void testGenerateSchemaFromClassSimple() { Map schema = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); @@ -168,6 +183,247 @@ void testGenerateSchemaFromType() { assertEquals("object", mapSchema.get("type")); } + @Test + void testGenerateSchemaFromClassRepeatedCallsReturnEqualIndependentMaps() { + Map first = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + Map second = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + + // A repeated class must yield an equal schema, so caching cannot change the result. + assertEquals(first, second); + + // Each call must return a fresh, independently mutable map: mutating one must not leak + // into another, matching in-place-mutating callers such as ToolSchemaGenerator. + first.put("description", "mutated"); + assertFalse(second.containsKey("description")); + + Map third = JsonSchemaUtils.generateSchemaFromClass(SimpleModel.class); + assertFalse(third.containsKey("description")); + assertEquals(second, third); + } + + @Test + void testGenerateSchemaFromTypeRepeatedCallsReturnEqualIndependentMaps() { + Type listType = new TypeReference>() {}.getType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(listType); + Map second = JsonSchemaUtils.generateSchemaFromType(listType); + + assertEquals(first, second); + + first.put("description", "mutated"); + assertFalse(second.containsKey("description")); + + Map third = JsonSchemaUtils.generateSchemaFromType(listType); + assertFalse(third.containsKey("description")); + assertEquals(second, third); + } + + @Test + void testGenerateSchemaFromClassNestedMutationDoesNotAffectLaterCalls() { + Map first = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class); + + @SuppressWarnings("unchecked") + Map firstProperties = (Map) first.get("properties"); + assertNotNull(firstProperties); + + // Real callers mutate below the top level: ToolSchemaGenerator hoists "$defs" out of + // nested schemas and ReActAgent rewrites nested properties in place. A later call must + // still observe the pristine schema, which is exactly the deep-copy invariant the cache + // relies on. + assertNotNull(firstProperties.remove("tags")); + @SuppressWarnings("unchecked") + Map firstAuthor = (Map) firstProperties.get("author"); + assertNotNull(firstAuthor); + firstAuthor.put("description", "mutated"); + + Map second = JsonSchemaUtils.generateSchemaFromClass(NestedModel.class); + + @SuppressWarnings("unchecked") + Map secondProperties = (Map) second.get("properties"); + assertNotNull(secondProperties); + assertTrue(secondProperties.containsKey("tags")); + + @SuppressWarnings("unchecked") + Map secondAuthor = (Map) secondProperties.get("author"); + assertNotNull(secondAuthor); + assertFalse(secondAuthor.containsKey("description")); + } + + @Test + void testGenerateSchemaFromTypeNestedMutationDoesNotAffectLaterCalls() { + Type listType = new TypeReference>() {}.getType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(listType); + + @SuppressWarnings("unchecked") + Map firstItems = (Map) first.get("items"); + assertNotNull(firstItems); + firstItems.put("description", "mutated"); + + Map second = JsonSchemaUtils.generateSchemaFromType(listType); + + @SuppressWarnings("unchecked") + Map secondItems = (Map) second.get("items"); + assertNotNull(secondItems); + assertFalse(secondItems.containsKey("description")); + } + + @Test + void testGenerateSchemaFromClassNullThrows() { + // Caching routes a null class through ClassValue#get, which rejects null keys; the + // resulting NPE must match the pre-cache behavior for a null argument. + assertThrows( + NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromClass(null)); + } + + @Test + void testGenerateSchemaFromTypeNullThrows() { + // The public entry point rejects null before it reaches the cache, matching the pre-cache + // behavior for a null argument. + assertThrows( + NullPointerException.class, () -> JsonSchemaUtils.generateSchemaFromType(null)); + } + + @Test + void testGenerateSchemaFromTypeVariableIsCachedUnderItsDeclaringClass() { + // A type variable has no raw class, so it is cached under the class that declares it. Both + // calls must agree, whether the entry is served from the cache or generated again. + Type typeVariable = List.class.getTypeParameters()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(typeVariable); + Map second = JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromMethodTypeVariableIsCachedUnderItsDeclaringClass() throws Exception { + // A type variable declared on a method has no raw class either, and its declaration is the + // method rather than a class, so it is cached under the method's declaring class. + Type typeVariable = + GenericHolder.class.getMethod("identity", Object.class).getTypeParameters()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(typeVariable); + Map second = JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromGenericArrayIsCachedUnderItsComponentClass() throws Exception { + // A generic array carries no raw class of its own; it resolves through its component type. + Type genericArray = GenericHolder.class.getField("values").getGenericType(); + + Map first = JsonSchemaUtils.generateSchemaFromType(genericArray); + Map second = JsonSchemaUtils.generateSchemaFromType(genericArray); + + assertNotNull(first); + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromWildcardIsGeneratedWithoutCaching() { + // A wildcard is only ever a type argument, never a parameter or return type, so a + // reflective signature cannot produce one at the top level. Only a caller synthesizing one + // reaches the uncached path; both calls must still agree. + Type wildcard = + ((ParameterizedType) new TypeReference>() {}.getType()) + .getActualTypeArguments()[0]; + + Map first = JsonSchemaUtils.generateSchemaFromType(wildcard); + Map second = JsonSchemaUtils.generateSchemaFromType(wildcard); + + assertEquals(first, second); + } + + @Test + void testGenerateSchemaFromParameterizedTypeIsHeldOnItsApplicationClass() throws Exception { + // List mentions java.util.List, which is never unloaded, and TenantElement. + // The entry has to live on the application class: parked on List, it would keep the element + // class, and the classloader that defined it, reachable for the lifetime of the JVM. + Type parameterized = new TypeReference>() {}.getType(); + + JsonSchemaUtils.generateSchemaFromType(parameterized); + + assertTrue(typeSlot(TenantElement.class).containsKey(parameterized)); + assertFalse(typeSlot(List.class).containsKey(parameterized)); + } + + @Test + void testGenerateSchemaFromBoundedWildcardIsHeldOnItsBoundClass() throws Exception { + // A wildcard is only ever a type argument, so the bound it declares decides which class the + // signature mentioning it belongs to. + Type boundedWildcard = new TypeReference>() {}.getType(); + + JsonSchemaUtils.generateSchemaFromType(boundedWildcard); + + assertTrue(typeSlot(TenantElement.class).containsKey(boundedWildcard)); + assertFalse(typeSlot(List.class).containsKey(boundedWildcard)); + } + + @Test + void testGenerateSchemaFromConstructorTypeVariableIsHeldOnItsDeclaringClass() throws Exception { + // A constructor declares type variables just as a method does, and it is a + // GenericDeclaration without being a Method. Matching only Method would send this type to + // the uncached path, which takes the global lock on every call. + Type typeVariable = + GenericConstructorHolder.class.getConstructor(Object.class).getTypeParameters()[0]; + + JsonSchemaUtils.generateSchemaFromType(typeVariable); + + assertTrue(typeSlot(GenericConstructorHolder.class).containsKey(typeVariable)); + } + + @Test + void testGenerateSchemaFromBareClassIsHeldOnTheClassSlotOnly() throws Exception { + // Both entry points describe the same class, so they must share one slot rather than + // generating and storing the same schema twice. + JsonSchemaUtils.generateSchemaFromType(BareClassFixture.class); + + assertNotNull(classSlot(BareClassFixture.class)); + assertFalse(typeSlot(BareClassFixture.class).containsKey(BareClassFixture.class)); + } + + /** + * Reads the private type slot of a class. Asserting the slot directly is the only way to check + * which class a type is cached under: every schema the public API returns is a fresh copy, so + * the entry it came from is invisible in behaviour. + */ + @SuppressWarnings("unchecked") + private static Map typeSlot(Class scopeClass) throws Exception { + Field slot = JsonSchemaUtils.class.getDeclaredField("TYPE_SCHEMA_SLOT"); + slot.setAccessible(true); + ClassValue> slots = (ClassValue>) slot.get(null); + return slots.get(scopeClass); + } + + /** Reads the private class slot of a class. */ + @SuppressWarnings("unchecked") + private static JsonNode classSlot(Class clazz) throws Exception { + Field slot = JsonSchemaUtils.class.getDeclaredField("CLASS_SCHEMA_SLOT"); + slot.setAccessible(true); + ClassValue> slots = + (ClassValue>) slot.get(null); + return slots.get(clazz).get(); + } + + /** Element class of the signatures used to check which class a type is cached under. */ + static class TenantElement { + public String name; + } + + /** Declares a type variable on a constructor, which is a GenericDeclaration but not a Method. */ + static class GenericConstructorHolder { + public GenericConstructorHolder(T value) {} + } + + /** Reached as a bare class through {@code generateSchemaFromType}. */ + static class BareClassFixture { + public String label; + } + static class ConcurrentClassA { public String name; public int age; @@ -207,10 +463,15 @@ void testGenerateSchemaFromClassConcurrently() throws Exception { @Test void testGenerateSchemaFromTypeConcurrently() throws Exception { - List targetTypes = - List.of( - new TypeReference() {}.getType(), - new TypeReference>() {}.getType()); + // The first two are variants of the same raw class and mention no application class, so + // both are held on java.util.List and share its slot map: they exercise the structure the + // cache's correctness rests on. The last two are held on the class each one mentions, so + // the scoped slots are stressed as well. + Type listOfString = new TypeReference>() {}.getType(); + Type listOfInteger = new TypeReference>() {}.getType(); + Type listOfC = new TypeReference>() {}.getType(); + Type listOfD = new TypeReference>() {}.getType(); + List targetTypes = List.of(listOfString, listOfInteger, listOfC, listOfD); List> schemas = generateConcurrently( @@ -221,8 +482,18 @@ void testGenerateSchemaFromTypeConcurrently() throws Exception { assertEquals(CONCURRENT_CALL_COUNT, schemas.size()); for (Map schema : schemas) { assertNotNull(schema); - assertNotNull(schema.get("type")); + assertEquals("array", schema.get("type")); } + + // Variants sharing one slot must stay independent of each other. + assertTrue(typeSlot(List.class).containsKey(listOfString)); + assertTrue(typeSlot(List.class).containsKey(listOfInteger)); + assertNotEquals( + JsonSchemaUtils.generateSchemaFromType(listOfString), + JsonSchemaUtils.generateSchemaFromType(listOfInteger)); + assertNotEquals( + JsonSchemaUtils.generateSchemaFromType(listOfC), + JsonSchemaUtils.generateSchemaFromType(listOfD)); } /**