You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tika.apache.org by ta...@apache.org on 2026/01/31 01:52:53 UTC
(tika) 02/02: TIKA-4641 -- simplify API and add duplicate detection
This is an automated email from the ASF dual-hosted git repository.
tallison pushed a commit to branch TIKA-4641
in repository https://gitbox.apache.org/repos/asf/tika.git
commit fe2967598d115f447f7df99fab6ec1c12e698181
Author: tallison <ta...@apache.org>
AuthorDate: Fri Jan 30 16:35:24 2026 -0500
TIKA-4641 -- simplify API and add duplicate detection
---
.../ROOT/pages/advanced/setting-limits.adoc | 40 +++---
docs/modules/ROOT/pages/advanced/spooling.adoc | 11 +-
.../ROOT/pages/configuration/digesters.adoc | 62 ++++-----
.../org/apache/tika/config/loader/TikaLoader.java | 139 +++++++++------------
.../serdes/ParseContextDeserializer.java | 64 +++++++++-
.../serdes/ParseContextSerializer.java | 14 +++
.../java/org/apache/tika/config/AllLimitsTest.java | 9 +-
.../org/apache/tika/config/EmbeddedLimitsTest.java | 3 +-
.../org/apache/tika/config/OutputLimitsTest.java | 3 +-
.../org/apache/tika/config/TimeoutLimitsTest.java | 3 +-
.../tika/config/loader/ConfigLoaderTest.java | 100 ++++++++-------
.../apache/tika/config/loader/TikaLoaderTest.java | 47 +++++++
.../TestParseContextSerialization.java | 64 ++++++++++
13 files changed, 360 insertions(+), 199 deletions(-)
diff --git a/docs/modules/ROOT/pages/advanced/setting-limits.adoc b/docs/modules/ROOT/pages/advanced/setting-limits.adoc
index 4dd02a4dd0..8064a4d88e 100644
--- a/docs/modules/ROOT/pages/advanced/setting-limits.adoc
+++ b/docs/modules/ROOT/pages/advanced/setting-limits.adoc
@@ -23,7 +23,7 @@ Tika provides several mechanisms for limiting resource usage during parsing.
== Overview
-Tika 4.x provides a unified configuration system for all limits through the `other-configs`
+Tika 4.x provides a unified configuration system for all limits through the `parse-context`
section of the JSON configuration file. All limits are loaded into the `ParseContext` and
flow through the parsing pipeline.
@@ -36,7 +36,7 @@ This is the same configuration tested in `AllLimitsTest.java`:
----
{
"parsers": ["default-parser"],
- "other-configs": {
+ "parse-context": {
"embedded-limits": {
"maxDepth": 10,
"throwOnMaxDepth": false,
@@ -54,13 +54,11 @@ This is the same configuration tested in `AllLimitsTest.java`:
"timeout-limits": {
"taskTimeoutMillis": 60000
},
- "metadata-write-limiter-factory": {
- "standard-metadata-limiter-factory": {
- "maxTotalBytes": 1048576,
- "maxFieldSize": 102400,
- "maxKeySize": 1024,
- "maxValuesPerField": 100
- }
+ "standard-metadata-limiter-factory": {
+ "maxTotalBytes": 1048576,
+ "maxFieldSize": 102400,
+ "maxKeySize": 1024,
+ "maxValuesPerField": 100
}
}
}
@@ -138,7 +136,7 @@ container.zip (depth 0)
[source,json]
----
{
- "other-configs": {
+ "parse-context": {
"embedded-limits": {
"maxDepth": 5,
"throwOnMaxDepth": true,
@@ -215,7 +213,7 @@ and protection against zip bombs.
[source,json]
----
{
- "other-configs": {
+ "parse-context": {
"output-limits": {
"writeLimit": 50000,
"throwOnWriteLimit": true,
@@ -263,7 +261,7 @@ The `TimeoutLimits` class controls time-based limits for parsing operations.
[source,json]
----
{
- "other-configs": {
+ "parse-context": {
"timeout-limits": {
"taskTimeoutMillis": 120000
}
@@ -358,16 +356,14 @@ Use this to extract only the metadata you need.
----
{
"parsers": ["default-parser"],
- "other-configs": {
- "metadata-write-limiter-factory": {
- "standard-metadata-limiter-factory": {
- "maxTotalBytes": 1048576,
- "maxFieldSize": 102400,
- "maxKeySize": 1024,
- "maxValuesPerField": 100,
- "includeFields": ["dc:title", "dc:creator", "dc:subject"],
- "excludeFields": ["pdf:unmappedUnicodeCharsPerPage"]
- }
+ "parse-context": {
+ "standard-metadata-limiter-factory": {
+ "maxTotalBytes": 1048576,
+ "maxFieldSize": 102400,
+ "maxKeySize": 1024,
+ "maxValuesPerField": 100,
+ "includeFields": ["dc:title", "dc:creator", "dc:subject"],
+ "excludeFields": ["pdf:unmappedUnicodeCharsPerPage"]
}
}
}
diff --git a/docs/modules/ROOT/pages/advanced/spooling.adoc b/docs/modules/ROOT/pages/advanced/spooling.adoc
index 81d3bb18e4..29b30bf297 100644
--- a/docs/modules/ROOT/pages/advanced/spooling.adoc
+++ b/docs/modules/ROOT/pages/advanced/spooling.adoc
@@ -159,12 +159,12 @@ The default spool types are:
=== JSON Configuration
SpoolingStrategy can be configured via JSON in your `tika-config.json` file.
-Place the configuration in the `other-configs` section:
+Place the configuration in the `parse-context` section:
[source,json]
----
{
- "other-configs": {
+ "parse-context": {
"spooling-strategy": {
"spoolTypes": [
"application/zip",
@@ -181,11 +181,8 @@ Load the configuration using `TikaLoader`:
[source,java]
----
TikaLoader loader = TikaLoader.load(Path.of("tika-config.json"));
-SpoolingStrategy strategy = loader.configs().load(SpoolingStrategy.class);
-
-// Add to parse context
-ParseContext context = new ParseContext();
-context.set(SpoolingStrategy.class, strategy);
+ParseContext context = loader.loadParseContext();
+// SpoolingStrategy is automatically loaded into the ParseContext
----
=== Best Practices
diff --git a/docs/modules/ROOT/pages/configuration/digesters.adoc b/docs/modules/ROOT/pages/configuration/digesters.adoc
index f09deb8446..b3e66f1dad 100644
--- a/docs/modules/ROOT/pages/configuration/digesters.adoc
+++ b/docs/modules/ROOT/pages/configuration/digesters.adoc
@@ -33,7 +33,7 @@ Tika provides two digester implementations:
== JSON Configuration
-Configure digesters in the `other-configs.digester-factory` section of your tika-config.json.
+Configure digesters in the `parse-context` section of your tika-config.json.
=== Basic Example with CommonsDigester
@@ -43,15 +43,13 @@ This example configures multiple digest algorithms:
[source,json]
----
{
- "other-configs": {
- "digester-factory": {
- "commons-digester-factory": {
- "digests": [
- { "algorithm": "MD5" },
- { "algorithm": "SHA256" },
- { "algorithm": "SHA512" }
- ]
- }
+ "parse-context": {
+ "commons-digester-factory": {
+ "digests": [
+ { "algorithm": "MD5" },
+ { "algorithm": "SHA256" },
+ { "algorithm": "SHA512" }
+ ]
}
}
}
@@ -65,15 +63,13 @@ For SHA3 algorithms, use the BouncyCastle digester:
[source,json]
----
{
- "other-configs": {
- "digester-factory": {
- "bouncy-castle-digester-factory": {
- "digests": [
- { "algorithm": "MD5" },
- { "algorithm": "SHA256" },
- { "algorithm": "SHA3_512" }
- ]
- }
+ "parse-context": {
+ "bouncy-castle-digester-factory": {
+ "digests": [
+ { "algorithm": "MD5" },
+ { "algorithm": "SHA256" },
+ { "algorithm": "SHA3_512" }
+ ]
}
}
}
@@ -87,14 +83,12 @@ By default, digest values are encoded as lowercase hexadecimal. You can specify
[source,json]
----
{
- "other-configs": {
- "digester-factory": {
- "commons-digester-factory": {
- "digests": [
- { "algorithm": "SHA256", "encoding": "BASE32" },
- { "algorithm": "MD5" }
- ]
- }
+ "parse-context": {
+ "commons-digester-factory": {
+ "digests": [
+ { "algorithm": "SHA256", "encoding": "BASE32" },
+ { "algorithm": "MD5" }
+ ]
}
}
}
@@ -112,14 +106,12 @@ to `true`:
[source,json]
----
{
- "other-configs": {
- "digester-factory": {
- "commons-digester-factory": {
- "digests": [
- { "algorithm": "MD5" }
- ],
- "skipContainerDocumentDigest": true
- }
+ "parse-context": {
+ "commons-digester-factory": {
+ "digests": [
+ { "algorithm": "MD5" }
+ ],
+ "skipContainerDocumentDigest": true
}
}
}
diff --git a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index 44c3c2b4a5..36b0f69325 100644
--- a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++ b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -24,7 +24,6 @@ import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.core.StreamReadConstraints;
@@ -58,6 +57,8 @@ import org.apache.tika.serialization.ComponentConfig;
import org.apache.tika.serialization.ComponentNameResolver;
import org.apache.tika.serialization.JsonMetadata;
import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.serialization.ParseContextUtils;
+import org.apache.tika.serialization.serdes.ParseContextDeserializer;
/**
* Main entry point for loading Tika components from JSON configuration.
@@ -381,8 +382,9 @@ public class TikaLoader {
/**
* Loads and returns a ParseContext populated with components from the "parse-context" section.
* <p>
- * This method only loads explicitly configured items from the JSON configuration.
- * For loading with defaults for missing items, use {@link #loadParseContextWithDefaults()}.
+ * This method deserializes the parse-context JSON and resolves all component references
+ * using the component registry. Components are looked up by their friendly names
+ * (e.g., "embedded-limits", "pdf-parser-config") and deserialized to their appropriate types.
* <p>
* Use this method when you need a pre-configured ParseContext for parsing operations.
*
@@ -399,106 +401,79 @@ public class TikaLoader {
* @throws TikaConfigException if loading fails
*/
public ParseContext loadParseContext() throws TikaConfigException {
- return loadParseContextInternal(false);
+ JsonNode parseContextNode = config.getRootNode().get("parse-context");
+ if (parseContextNode == null) {
+ return new ParseContext();
+ }
+ try {
+ ParseContext context =
+ ParseContextDeserializer.readParseContext(parseContextNode, objectMapper);
+ ParseContextUtils.resolveAll(context, classLoader);
+ return context;
+ } catch (IOException e) {
+ throw new TikaConfigException("Failed to load parse-context", e);
+ }
}
/**
- * Loads and returns a ParseContext populated with components from the "parse-context" section,
- * plus default implementations for any missing items.
+ * Loads a configuration object from the "parse-context" section, merging with defaults.
+ * <p>
+ * This method is useful when you have a base configuration (e.g., from code defaults or
+ * a previous load) and want to overlay values from the JSON config. Properties not
+ * specified in the JSON retain their default values.
* <p>
- * This method loads explicitly configured items from JSON, then instantiates
- * default implementations (marked with {@code @TikaComponent(defaultFor=...)})
- * for any interface that wasn't explicitly configured.
+ * The original defaults object is NOT modified - a new instance is returned.
+ *
+ * <p>Example usage for PDFParserConfig:
+ * <pre>
+ * // Load base config from tika-config.json at init time
+ * TikaLoader loader = TikaLoader.load(configPath);
+ * PDFParserConfig baseConfig = loader.loadConfig(PDFParserConfig.class, new PDFParserConfig());
+ *
+ * // At runtime, create per-request overrides
+ * PDFParserConfig requestConfig = new PDFParserConfig();
+ * requestConfig.setOcrStrategy(PDFParserConfig.OCR_STRATEGY.NO_OCR);
+ *
+ * // Merge: base config values + request overrides
+ * // (Note: for runtime merging, use JsonMergeUtils directly or loadConfig on a runtime loader)
+ * </pre>
*
- * @return a ParseContext populated with configured and default components
+ * @param clazz the class to deserialize into
+ * @param defaults the default values to use for properties not in the JSON config
+ * @param <T> the configuration type
+ * @return a new instance with defaults merged with JSON config, or the original defaults if not configured
* @throws TikaConfigException if loading fails
*/
- public ParseContext loadParseContextWithDefaults() throws TikaConfigException {
- return loadParseContextInternal(true);
+ public <T> T loadConfig(Class<T> clazz, T defaults) throws TikaConfigException {
+ return configs().loadWithDefaults(clazz, defaults);
}
/**
- * Internal method to load ParseContext with optional defaults.
+ * Loads a configuration object from the "parse-context" section by explicit key, merging with defaults.
+ * <p>
+ * This method is useful when the JSON key doesn't match the class name's kebab-case conversion,
+ * or when you want to load from a specific key.
*
- * @param includeDefaults whether to include default implementations for missing items
- * @return a ParseContext populated with components
+ * @param key the JSON key in the "parse-context" section
+ * @param clazz the class to deserialize into
+ * @param defaults the default values to use for properties not in the JSON config
+ * @param <T> the configuration type
+ * @return a new instance with defaults merged with JSON config, or the original defaults if not configured
* @throws TikaConfigException if loading fails
*/
- private ParseContext loadParseContextInternal(boolean includeDefaults) throws TikaConfigException {
- ParseContext context = new ParseContext();
- Set<Class<?>> configuredKeys = new HashSet<>();
-
- // Load the component registry for parse-context
- ComponentRegistry registry;
- try {
- registry = new ComponentRegistry("parse-context", classLoader);
- } catch (TikaConfigException e) {
- // parse-context.idx might not exist yet (e.g., first build)
- // In that case, just return an empty context
- return context;
- }
-
- // Load explicitly configured items from JSON
- JsonNode parseContextNode = config.getRootNode().get("parse-context");
- if (parseContextNode != null && parseContextNode.isObject()) {
- java.util.Iterator<String> fieldNames = parseContextNode.fieldNames();
- while (fieldNames.hasNext()) {
- String key = fieldNames.next();
- JsonNode valueNode = parseContextNode.get(key);
-
- try {
- ComponentInfo info = registry.getComponentInfo(key);
- Class<?> targetClass = info.componentClass();
- Class<?> contextKey = info.contextKey() != null ? info.contextKey() : targetClass;
-
- Object instance = objectMapper.treeToValue(valueNode, targetClass);
- context.set((Class<Object>) contextKey, instance);
- configuredKeys.add(contextKey);
- } catch (TikaConfigException e) {
- throw new TikaConfigException("Failed to load parse-context item: " + key, e);
- } catch (Exception e) {
- throw new TikaConfigException("Failed to deserialize parse-context item: " + key, e);
- }
- }
- }
-
- // Add defaults for missing items (if requested)
- if (includeDefaults) {
- for (Map.Entry<String, ComponentInfo> entry : registry.getDefaultComponents().entrySet()) {
- ComponentInfo info = entry.getValue();
- Class<?> contextKey = info.contextKey() != null ? info.contextKey() : info.componentClass();
-
- if (!configuredKeys.contains(contextKey)) {
- try {
- Object instance = info.componentClass().getDeclaredConstructor().newInstance();
- context.set((Class<Object>) contextKey, instance);
- } catch (ReflectiveOperationException e) {
- throw new TikaConfigException(
- "Failed to instantiate default component: " + info.componentClass().getName(), e);
- }
- }
- }
- }
-
- return context;
+ public <T> T loadConfig(String key, Class<T> clazz, T defaults) throws TikaConfigException {
+ return configs().loadWithDefaults(key, clazz, defaults);
}
/**
* Returns a ConfigLoader for loading simple configuration objects.
* <p>
- * Use this for POJOs and simple config classes. For complex components like
- * Parsers, Detectors, etc., use the specific load methods on TikaLoader.
- *
- * <p>Usage:
- * <pre>
- * MyConfig config = loader.configs().load("my-config", MyConfig.class);
- * // Or use kebab-case auto-conversion:
- * MyConfig config = loader.configs().load(MyConfig.class);
- * </pre>
+ * This is internal - external code should use {@link #loadParseContext()} or
+ * {@link #loadConfig(Class, Object)} instead.
*
* @return the ConfigLoader instance
*/
- public synchronized ConfigLoader configs() {
+ private synchronized ConfigLoader configs() {
if (configLoader == null) {
configLoader = new ConfigLoader(config, objectMapper);
}
diff --git a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
index 2e2b4dec7d..3cc05f3272 100644
--- a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
+++ b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextDeserializer.java
@@ -20,7 +20,9 @@ import static org.apache.tika.serialization.serdes.ParseContextSerializer.PARSE_
import static org.apache.tika.serialization.serdes.ParseContextSerializer.TYPED;
import java.io.IOException;
+import java.util.HashMap;
import java.util.Iterator;
+import java.util.Map;
import java.util.Optional;
import com.fasterxml.jackson.core.JsonParser;
@@ -75,11 +77,15 @@ public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {
* <p>
* The "typed" section is deserialized directly to typed objects in the context map.
* All other fields are stored as JSON config strings for lazy resolution.
+ * <p>
+ * Duplicate detection is performed within a single document: if multiple entries
+ * resolve to the same context key (e.g., both "bouncy-castle-digester" and
+ * "commons-digester" resolve to DigesterFactory), an IOException is thrown.
*
* @param jsonNode the JSON node containing the ParseContext data
* @param mapper the ObjectMapper for deserializing typed objects
* @return the deserialized ParseContext
- * @throws IOException if deserialization fails
+ * @throws IOException if deserialization fails or duplicate context keys are detected
*/
public static ParseContext readParseContext(JsonNode jsonNode, ObjectMapper mapper)
throws IOException {
@@ -95,6 +101,10 @@ public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {
return parseContext;
}
+ // Track context keys to detect duplicates within this document
+ // Maps contextKey -> friendlyName for error messages
+ Map<Class<?>, String> seenContextKeys = new HashMap<>();
+
Iterator<String> fieldNames = contextNode.fieldNames();
while (fieldNames.hasNext()) {
String name = fieldNames.next();
@@ -102,8 +112,11 @@ public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {
if (TYPED.equals(name)) {
// Deserialize typed objects directly to context map
- deserializeTypedObjects(value, parseContext, mapper);
+ deserializeTypedObjects(value, parseContext, mapper, seenContextKeys);
} else {
+ // Check for duplicate context key before storing
+ checkForDuplicateContextKey(name, seenContextKeys);
+
// Store as JSON config for lazy resolution
// Use plain JSON mapper since the main mapper may be binary (Smile)
String json = JSON_MAPPER.writeValueAsString(value);
@@ -114,12 +127,49 @@ public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {
return parseContext;
}
+ /**
+ * Checks if a JSON config entry would create a duplicate context key.
+ * <p>
+ * Looks up the friendly name in the component registry to determine its context key,
+ * then checks if that key has already been seen in this document.
+ *
+ * @param friendlyName the friendly name of the config entry
+ * @param seenContextKeys map of already-seen context keys to their friendly names
+ * @throws IOException if a duplicate context key is detected
+ */
+ private static void checkForDuplicateContextKey(String friendlyName,
+ Map<Class<?>, String> seenContextKeys)
+ throws IOException {
+ Optional<ComponentInfo> infoOpt = ComponentNameResolver.getComponentInfo(friendlyName);
+ if (infoOpt.isEmpty()) {
+ // Not a registered component - can't check for duplicates, that's okay
+ return;
+ }
+
+ ComponentInfo info = infoOpt.get();
+ Class<?> contextKey = info.contextKey() != null ? info.contextKey() : info.componentClass();
+
+ String existingName = seenContextKeys.get(contextKey);
+ if (existingName != null) {
+ throw new IOException("Duplicate parse-context entries resolve to the same key " +
+ contextKey.getName() + ": '" + existingName + "' and '" + friendlyName + "'");
+ }
+ seenContextKeys.put(contextKey, friendlyName);
+ }
+
/**
* Deserializes the "typed" section into typed objects in the context map.
+ *
+ * @param typedNode the JSON node containing typed objects
+ * @param parseContext the ParseContext to add objects to
+ * @param mapper the ObjectMapper for deserializing
+ * @param seenContextKeys map tracking context keys to their friendly names (for duplicate detection)
+ * @throws IOException if deserialization fails or duplicate context keys are detected
*/
@SuppressWarnings("unchecked")
private static void deserializeTypedObjects(JsonNode typedNode, ParseContext parseContext,
- ObjectMapper mapper) throws IOException {
+ ObjectMapper mapper,
+ Map<Class<?>, String> seenContextKeys) throws IOException {
if (!typedNode.isObject()) {
return;
}
@@ -158,6 +208,14 @@ public class ParseContextDeserializer extends JsonDeserializer<ParseContext> {
// Use contextKey if available, otherwise use the config class itself
Class<?> parseContextKey = (contextKeyClass != null) ? contextKeyClass : configClass;
+ // Check for duplicate context key
+ String existingName = seenContextKeys.get(parseContextKey);
+ if (existingName != null) {
+ throw new IOException("Duplicate parse-context entries resolve to the same key " +
+ parseContextKey.getName() + ": '" + existingName + "' and '" + componentName + "'");
+ }
+ seenContextKeys.put(parseContextKey, componentName);
+
// Deserialize and add to context
try {
Object config = mapper.treeToValue(configNode, configClass);
diff --git a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java
index cf73ad8129..643d76f5c2 100644
--- a/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java
+++ b/tika-serialization/src/main/java/org/apache/tika/serialization/serdes/ParseContextSerializer.java
@@ -17,7 +17,9 @@
package org.apache.tika.serialization.serdes;
import java.io.IOException;
+import java.util.HashSet;
import java.util.Map;
+import java.util.Set;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
@@ -62,6 +64,10 @@ public class ParseContextSerializer extends JsonSerializer<ParseContext> {
SerializerProvider serializers) throws IOException {
gen.writeStartObject();
+ // Track which friendly names have been serialized under "typed"
+ // so we can skip them when serializing jsonConfigs (avoid duplicates)
+ Set<String> serializedNames = new HashSet<>();
+
// First, serialize typed objects from the context map under "typed" key
Map<String, Object> contextMap = parseContext.getContextMap();
boolean hasTypedObjects = false;
@@ -94,6 +100,9 @@ public class ParseContextSerializer extends JsonSerializer<ParseContext> {
// Use writeTree instead of writeRawValue for binary format support (e.g., Smile)
// and stricter validation (fails early if value can't be serialized)
gen.writeTree(PLAIN_MAPPER.valueToTree(value));
+
+ // Track this name so we skip it in jsonConfigs
+ serializedNames.add(keyName);
}
if (hasTypedObjects) {
@@ -101,8 +110,13 @@ public class ParseContextSerializer extends JsonSerializer<ParseContext> {
}
// Then, serialize JSON configs at the top level
+ // Skip entries that were already serialized under "typed" (they've been resolved)
Map<String, JsonConfig> jsonConfigs = parseContext.getJsonConfigs();
for (Map.Entry<String, JsonConfig> entry : jsonConfigs.entrySet()) {
+ if (serializedNames.contains(entry.getKey())) {
+ // Already serialized under "typed", skip to avoid duplicate
+ continue;
+ }
gen.writeFieldName(entry.getKey());
// Parse the JSON string into a tree for binary format support
gen.writeTree(PLAIN_MAPPER.readTree(entry.getValue().json()));
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/AllLimitsTest.java b/tika-serialization/src/test/java/org/apache/tika/config/AllLimitsTest.java
index 2dd10b532f..b509d03bab 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/AllLimitsTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/AllLimitsTest.java
@@ -105,17 +105,18 @@ public class AllLimitsTest extends TikaTest {
@Test
public void testLoadIndividualLimits() throws Exception {
TikaLoader loader = TikaLoader.load(getConfigPath(getClass(), "all-limits-test.json"));
+ ParseContext context = loader.loadParseContext();
- // Load individual limit configs directly
- EmbeddedLimits embeddedLimits = loader.configs().load(EmbeddedLimits.class);
+ // Load individual limit configs from ParseContext
+ EmbeddedLimits embeddedLimits = context.get(EmbeddedLimits.class);
assertNotNull(embeddedLimits);
assertEquals(10, embeddedLimits.getMaxDepth());
- OutputLimits outputLimits = loader.configs().load(OutputLimits.class);
+ OutputLimits outputLimits = context.get(OutputLimits.class);
assertNotNull(outputLimits);
assertEquals(100000, outputLimits.getWriteLimit());
- TimeoutLimits timeoutLimits = loader.configs().load(TimeoutLimits.class);
+ TimeoutLimits timeoutLimits = context.get(TimeoutLimits.class);
assertNotNull(timeoutLimits);
assertEquals(60000, timeoutLimits.getTaskTimeoutMillis());
}
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/EmbeddedLimitsTest.java b/tika-serialization/src/test/java/org/apache/tika/config/EmbeddedLimitsTest.java
index f362271fb3..4bb4873701 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/EmbeddedLimitsTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/EmbeddedLimitsTest.java
@@ -32,7 +32,8 @@ public class EmbeddedLimitsTest extends TikaTest {
@Test
public void testLoadFromConfig() throws Exception {
TikaLoader loader = TikaLoader.load(getConfigPath(getClass(), "embedded-limits-test.json"));
- EmbeddedLimits limits = loader.configs().load(EmbeddedLimits.class);
+ ParseContext context = loader.loadParseContext();
+ EmbeddedLimits limits = context.get(EmbeddedLimits.class);
assertNotNull(limits);
assertEquals(5, limits.getMaxDepth());
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/OutputLimitsTest.java b/tika-serialization/src/test/java/org/apache/tika/config/OutputLimitsTest.java
index 6e543a085d..41e23cd570 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/OutputLimitsTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/OutputLimitsTest.java
@@ -32,7 +32,8 @@ public class OutputLimitsTest extends TikaTest {
@Test
public void testLoadFromConfig() throws Exception {
TikaLoader loader = TikaLoader.load(getConfigPath(getClass(), "output-limits-test.json"));
- OutputLimits limits = loader.configs().load(OutputLimits.class);
+ ParseContext context = loader.loadParseContext();
+ OutputLimits limits = context.get(OutputLimits.class);
assertNotNull(limits);
assertEquals(50000, limits.getWriteLimit());
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/TimeoutLimitsTest.java b/tika-serialization/src/test/java/org/apache/tika/config/TimeoutLimitsTest.java
index 24c15af7ab..4217349134 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/TimeoutLimitsTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/TimeoutLimitsTest.java
@@ -32,7 +32,8 @@ public class TimeoutLimitsTest extends TikaTest {
@Test
public void testLoadFromConfig() throws Exception {
TikaLoader loader = TikaLoader.load(getConfigPath(getClass(), "timeout-limits-test.json"));
- TimeoutLimits limits = loader.configs().load(TimeoutLimits.class);
+ ParseContext context = loader.loadParseContext();
+ TimeoutLimits limits = context.get(TimeoutLimits.class);
assertNotNull(limits);
assertEquals(120000, limits.getTaskTimeoutMillis());
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/loader/ConfigLoaderTest.java b/tika-serialization/src/test/java/org/apache/tika/config/loader/ConfigLoaderTest.java
index 12695472c8..5efa4136ac 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/loader/ConfigLoaderTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/loader/ConfigLoaderTest.java
@@ -27,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.nio.file.Paths;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -39,15 +40,16 @@ import org.apache.tika.mime.MediaType;
*/
public class ConfigLoaderTest {
- private TikaLoader tikaLoader;
+ private TikaJsonConfig tikaJsonConfig;
private ConfigLoader configLoader;
@BeforeEach
public void setUp() throws Exception {
Path configPath = Paths.get(
getClass().getResource("/configs/test-config-loader.json").toURI());
- tikaLoader = TikaLoader.load(configPath);
- configLoader = tikaLoader.configs();
+ tikaJsonConfig = TikaJsonConfig.load(configPath);
+ ObjectMapper objectMapper = TikaObjectMapperFactory.getMapper();
+ configLoader = new ConfigLoader(tikaJsonConfig, objectMapper);
}
// ==================== Test POJOs ====================
@@ -286,10 +288,11 @@ public class ConfigLoaderTest {
// because Jackson can't instantiate interfaces directly
Path configPath = Paths.get(
getClass().getResource("/configs/test-interface-no-type.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
- loader.configs().load("handler-no-type", TestHandler.class));
+ loader.load("handler-no-type", TestHandler.class));
assertTrue(ex.getMessage().contains("Failed to deserialize"));
}
@@ -337,10 +340,11 @@ public class ConfigLoaderTest {
public void testLoadInvalidClassName() throws Exception {
Path configPath = Paths.get(
getClass().getResource("/configs/test-invalid-class.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
- loader.configs().load("handler", TestHandler.class));
+ loader.load("handler", TestHandler.class));
assertTrue(ex.getMessage().contains("Class not found"));
}
@@ -350,10 +354,11 @@ public class ConfigLoaderTest {
// String class name that doesn't implement the interface
Path configPath = Paths.get(
getClass().getResource("/configs/test-wrong-type.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
- loader.configs().load("handler", TestHandler.class));
+ loader.load("handler", TestHandler.class));
assertTrue(ex.getMessage().contains("not assignable"));
}
@@ -363,10 +368,11 @@ public class ConfigLoaderTest {
// Verify that unexpected/unrecognized fields cause an exception
Path configPath = Paths.get(
getClass().getResource("/configs/test-unexpected-field.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaConfigException ex = assertThrows(TikaConfigException.class, () ->
- loader.configs().load("retry-config", RetryConfig.class));
+ loader.load("retry-config", RetryConfig.class));
// Should contain information about the unrecognized field
assertTrue(ex.getMessage().contains("retry-config") ||
@@ -407,7 +413,8 @@ public class ConfigLoaderTest {
// Load config that merges defaults with partial JSON
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
// Set up defaults
RetryConfig defaults = new RetryConfig();
@@ -416,14 +423,14 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// JSON only has: { "enabled": true }
- RetryConfig config = loader.configs().loadWithDefaults("retry-config",
+ RetryConfig result = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
- assertNotNull(config);
- assertEquals(30000, config.getTimeout()); // ✅ From defaults
- assertEquals(2, config.getRetries()); // ✅ From defaults
- assertTrue(config.isEnabled()); // ✅ From JSON (overridden)
+ assertNotNull(result);
+ assertEquals(30000, result.getTimeout()); // ✅ From defaults
+ assertEquals(2, result.getRetries()); // ✅ From defaults
+ assertTrue(result.isEnabled()); // ✅ From JSON (overridden)
}
@Test
@@ -431,7 +438,8 @@ public class ConfigLoaderTest {
// Test that JSON can override all defaults
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
@@ -439,14 +447,14 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// JSON has: { "timeout": 10000, "retries": 5, "enabled": false }
- RetryConfig config = loader.configs().loadWithDefaults("retry-config-full",
+ RetryConfig result = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
defaults);
- assertNotNull(config);
- assertEquals(10000, config.getTimeout()); // All overridden
- assertEquals(5, config.getRetries());
- assertFalse(config.isEnabled());
+ assertNotNull(result);
+ assertEquals(10000, result.getTimeout()); // All overridden
+ assertEquals(5, result.getRetries());
+ assertFalse(result.isEnabled());
}
@Test
@@ -472,7 +480,8 @@ public class ConfigLoaderTest {
// Test the class-name version
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
@@ -480,12 +489,12 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// Uses kebab-case: RetryConfig -> "retry-config"
- RetryConfig config = loader.configs().loadWithDefaults(RetryConfig.class, defaults);
+ RetryConfig result = loader.loadWithDefaults(RetryConfig.class, defaults);
- assertNotNull(config);
- assertEquals(30000, config.getTimeout());
- assertEquals(2, config.getRetries());
- assertTrue(config.isEnabled()); // Overridden from JSON
+ assertNotNull(result);
+ assertEquals(30000, result.getTimeout());
+ assertEquals(2, result.getRetries());
+ assertTrue(result.isEnabled()); // Overridden from JSON
}
@Test
@@ -493,7 +502,8 @@ public class ConfigLoaderTest {
// Demonstrate difference between load() and loadWithDefaults()
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
@@ -501,13 +511,13 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// Using load() - creates new object, loses defaults
- RetryConfig config1 = loader.configs().load("retry-config", RetryConfig.class);
+ RetryConfig config1 = loader.load("retry-config", RetryConfig.class);
assertEquals(0, config1.getTimeout()); // ❌ Lost default!
assertEquals(0, config1.getRetries()); // ❌ Lost default!
assertTrue(config1.isEnabled()); // ✅ From JSON
// Using loadWithDefaults() - merges into defaults
- RetryConfig config2 = loader.configs().loadWithDefaults("retry-config",
+ RetryConfig config2 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
assertEquals(30000, config2.getTimeout()); // ✅ Kept default!
@@ -522,7 +532,8 @@ public class ConfigLoaderTest {
// Verify that the original defaults object is NOT modified
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
@@ -530,7 +541,7 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// Load config with partial override (JSON only has "enabled": true)
- RetryConfig result = loader.configs().loadWithDefaults("retry-config",
+ RetryConfig result = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
@@ -555,7 +566,8 @@ public class ConfigLoaderTest {
// Verify defaults can be safely reused for multiple loads
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
RetryConfig defaults = new RetryConfig();
defaults.setTimeout(30000);
@@ -563,10 +575,10 @@ public class ConfigLoaderTest {
defaults.setEnabled(false);
// Load multiple times with same defaults
- RetryConfig config1 = loader.configs().loadWithDefaults("retry-config",
+ RetryConfig config1 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
defaults);
- RetryConfig config2 = loader.configs().loadWithDefaults("retry-config-full",
+ RetryConfig config2 = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
defaults);
@@ -580,7 +592,7 @@ public class ConfigLoaderTest {
assertFalse(defaults.isEnabled());
// Use defaults one more time
- RetryConfig config3 = loader.configs().loadWithDefaults("non-existent",
+ RetryConfig config3 = loader.loadWithDefaults("non-existent",
RetryConfig.class,
defaults);
assertEquals(defaults, config3); // Should return original when key missing
@@ -591,13 +603,14 @@ public class ConfigLoaderTest {
// Test with nested/complex objects to ensure deep copy works
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
TikaTaskTimeout defaults = new TikaTaskTimeout();
defaults.setMillis(60000);
// Note: tika-task-timeout in JSON has millis: 30000
- TikaTaskTimeout result = loader.configs().loadWithDefaults("tika-task-timeout",
+ TikaTaskTimeout result = loader.loadWithDefaults("tika-task-timeout",
TikaTaskTimeout.class,
defaults);
@@ -632,7 +645,8 @@ public class ConfigLoaderTest {
// Demonstrate that defaults can be safely shared across threads
Path configPath = Paths.get(
getClass().getResource("/configs/test-partial-config.json").toURI());
- TikaLoader loader = TikaLoader.load(configPath);
+ TikaJsonConfig config = TikaJsonConfig.load(configPath);
+ ConfigLoader loader = new ConfigLoader(config, TikaObjectMapperFactory.getMapper());
// Shared defaults object
RetryConfig sharedDefaults = new RetryConfig();
@@ -641,10 +655,10 @@ public class ConfigLoaderTest {
sharedDefaults.setEnabled(false);
// Simulate concurrent usage (not a real concurrency test, just demonstrates safety)
- RetryConfig result1 = loader.configs().loadWithDefaults("retry-config",
+ RetryConfig result1 = loader.loadWithDefaults("retry-config",
RetryConfig.class,
sharedDefaults);
- RetryConfig result2 = loader.configs().loadWithDefaults("retry-config-full",
+ RetryConfig result2 = loader.loadWithDefaults("retry-config-full",
RetryConfig.class,
sharedDefaults);
diff --git a/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java b/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
index 5b8e44c788..403464d9c4 100644
--- a/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
+++ b/tika-serialization/src/test/java/org/apache/tika/config/loader/TikaLoaderTest.java
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.xml.sax.helpers.DefaultHandler;
+import org.apache.tika.config.EmbeddedLimits;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
@@ -322,6 +323,52 @@ public class TikaLoaderTest {
"Should NOT support application/test+optin (opt-in only, not in SPI)");
}
+ @Test
+ public void testLoadConfigWithDefaults() throws Exception {
+ // Test the loadConfig method that merges JSON config with defaults
+ URL configUrl = getClass().getResource("/configs/embedded-limits-test.json");
+ Path configPath = Path.of(configUrl.toURI());
+
+ TikaLoader loader = TikaLoader.load(configPath);
+
+ // Create defaults - some values will be overridden by JSON, others kept
+ EmbeddedLimits defaults = new EmbeddedLimits();
+ // Default values from EmbeddedLimits: maxDepth=UNLIMITED, maxCount=UNLIMITED, throwOnMax*=false
+
+ // Load with defaults - JSON has: maxDepth=5, throwOnMaxDepth=true, maxCount=100, throwOnMaxCount=false
+ EmbeddedLimits config = loader.loadConfig(EmbeddedLimits.class, defaults);
+
+ assertNotNull(config, "Config should not be null");
+ assertEquals(5, config.getMaxDepth(), "maxDepth should be from JSON");
+ assertTrue(config.isThrowOnMaxDepth(), "throwOnMaxDepth should be from JSON");
+ assertEquals(100, config.getMaxCount(), "maxCount should be from JSON");
+ assertFalse(config.isThrowOnMaxCount(), "throwOnMaxCount should be from JSON");
+
+ // Verify original defaults object was NOT modified
+ assertEquals(EmbeddedLimits.UNLIMITED, defaults.getMaxDepth(), "Original defaults should be unchanged");
+ }
+
+ @Test
+ public void testLoadConfigMissingKeyReturnsDefaults() throws Exception {
+ // Test that loadConfig returns defaults when key is not in config
+ URL configUrl = getClass().getResource("/configs/test-loader-config.json");
+ Path configPath = Path.of(configUrl.toURI());
+
+ TikaLoader loader = TikaLoader.load(configPath);
+
+ // Create defaults
+ EmbeddedLimits defaults = new EmbeddedLimits(10, true, 500, false);
+
+ // Load with defaults - this config doesn't have embedded-limits
+ EmbeddedLimits config = loader.loadConfig(EmbeddedLimits.class, defaults);
+
+ // Should return the defaults since key is missing
+ assertEquals(10, config.getMaxDepth(), "Should return defaults when key missing");
+ assertTrue(config.isThrowOnMaxDepth(), "Should return defaults when key missing");
+ assertEquals(500, config.getMaxCount(), "Should return defaults when key missing");
+ assertFalse(config.isThrowOnMaxCount(), "Should return defaults when key missing");
+ }
+
// TODO: TIKA-SERIALIZATION-FOLLOWUP - Jackson may need configuration to fail on unknown properties
@Disabled("TIKA-SERIALIZATION-FOLLOWUP")
@Test
diff --git a/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java b/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java
index dcd99f1b01..f22ae013ce 100644
--- a/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java
+++ b/tika-serialization/src/test/java/org/apache/tika/serialization/TestParseContextSerialization.java
@@ -19,6 +19,7 @@ package org.apache.tika.serialization;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
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 java.io.StringWriter;
@@ -429,4 +430,67 @@ public class TestParseContextSerialization {
assertEquals(10000, basicFactory.getWriteLimit());
assertFalse(basicFactory.isThrowOnWriteLimitReached());
}
+
+ /**
+ * Test that duplicate context keys within a single JSON document are detected and rejected.
+ * Both BasicContentHandlerFactory and UppercasingContentHandlerFactory resolve to
+ * ContentHandlerFactory.class as their context key, so configuring both should fail.
+ */
+ @Test
+ public void testDuplicateContextKeyDetection() throws Exception {
+ // Both of these resolve to ContentHandlerFactory.class as the context key
+ String json = """
+ {
+ "basic-content-handler-factory": {
+ "type": "XML",
+ "writeLimit": 50000
+ },
+ "uppercasing-content-handler-factory": {}
+ }
+ """;
+
+ ObjectMapper mapper = createMapper();
+
+ // Should throw an exception due to duplicate context key
+ Exception ex = assertThrows(Exception.class, () ->
+ mapper.readValue(json, ParseContext.class));
+
+ // Verify the error message mentions the duplicate
+ assertTrue(ex.getMessage().contains("Duplicate") ||
+ (ex.getCause() != null && ex.getCause().getMessage().contains("Duplicate")),
+ "Exception should mention duplicate context key: " + ex.getMessage());
+ assertTrue(ex.getMessage().contains("ContentHandlerFactory") ||
+ (ex.getCause() != null && ex.getCause().getMessage().contains("ContentHandlerFactory")),
+ "Exception should mention the conflicting key: " + ex.getMessage());
+ }
+
+ /**
+ * Test that a single component per context key is allowed (no false positives).
+ */
+ @Test
+ public void testNoDuplicateWhenDifferentContextKeys() throws Exception {
+ // These have different context keys, so both should be allowed
+ String json = """
+ {
+ "basic-content-handler-factory": {
+ "type": "TEXT",
+ "writeLimit": 10000
+ },
+ "skip-embedded-document-selector": {}
+ }
+ """;
+
+ ObjectMapper mapper = createMapper();
+ ParseContext deserialized = mapper.readValue(json, ParseContext.class);
+
+ // Both should be present as JSON configs
+ assertTrue(deserialized.hasJsonConfig("basic-content-handler-factory"));
+ assertTrue(deserialized.hasJsonConfig("skip-embedded-document-selector"));
+
+ // Resolve and verify both work
+ ParseContextUtils.resolveAll(deserialized, Thread.currentThread().getContextClassLoader());
+
+ assertNotNull(deserialized.get(ContentHandlerFactory.class));
+ assertNotNull(deserialized.get(DocumentSelector.class));
+ }
}