This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new bfe1a42704 TIKA-4856 presets (#3118)
bfe1a42704 is described below
commit bfe1a42704b177f5b8a574fc452b6f7b97a537e7
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 3 17:33:29 2026 -0400
TIKA-4856 presets (#3118)
---
CHANGES.txt | 2 +
docs/modules/ROOT/pages/pipes/configuration.adoc | 2 +-
docs/modules/ROOT/pages/pipes/plugins/json.adoc | 2 +-
.../ROOT/pages/using-tika/server/index.adoc | 56 +++++
.../org/apache/tika/pipes/api/FetchEmitTuple.java | 24 +-
.../org/apache/tika/pipes/api/PipesResult.java | 1 +
.../serialization/FetchEmitTupleDeserializer.java | 17 +-
.../serialization/FetchEmitTupleSerializer.java | 4 +
.../pipes/core/serialization/PipesRequest.java | 2 +-
.../tika/pipes/core/server/ConnectionHandler.java | 8 +-
.../apache/tika/pipes/core/server/PipesServer.java | 41 +++-
.../pipes/core/server/PresetNotFoundException.java | 32 +++
.../pipes/core/server/SharedServerResources.java | 21 +-
.../core/serialization/JsonFetchEmitTupleTest.java | 27 +++
.../tika/pipes/core/server/PresetMergeTest.java | 90 +++++++
.../apache/tika/config/loader/PresetRegistry.java | 259 +++++++++++++++++++++
.../apache/tika/config/loader/TikaJsonConfig.java | 1 +
.../tika/config/loader/PresetRegistryTest.java | 230 ++++++++++++++++++
.../META-INF/tika/preset-catalog.properties | 19 ++
.../resources/test-presets/builtin-sample.json | 4 +
.../server/core/resource/PipesParsingHelper.java | 24 +-
.../tika/server/core/resource/PresetSelection.java | 28 +++
.../core/resource/RecursiveMetadataResource.java | 39 ++++
.../tika/server/core/resource/TikaResource.java | 159 ++++++++++++-
.../server/core/resource/UnpackerResource.java | 51 ++++
.../org/apache/tika/server/core/CXFTestBase.java | 6 +
.../tika/server/core/PresetEndpointsTest.java | 192 +++++++++++++++
.../core/resource/TikaResourcePresetTest.java | 96 ++++++++
28 files changed, 1414 insertions(+), 23 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index fe2cc602cb..c24adef94e 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
Release 4.1.0 - unreleased
+ * tika-server: named configuration presets (TIKA-4856).
+
* Temp files follow -Djava.io.tmpdir on the parent JVM (Tika, its
libraries, and forks all honor it); TikaLoader fails at config load
if it is unusable. pipes.tempDirectory is deprecated for removal in
diff --git a/docs/modules/ROOT/pages/pipes/configuration.adoc
b/docs/modules/ROOT/pages/pipes/configuration.adoc
index d8691bf7a8..6e65a53d82 100644
--- a/docs/modules/ROOT/pages/pipes/configuration.adoc
+++ b/docs/modules/ROOT/pages/pipes/configuration.adoc
@@ -136,7 +136,7 @@ See also xref:pipes/timeouts.adoc[Timeouts] for the full
timeout model.
|`maxTotalTaskTimeoutMillis`
|`3600000`
-|Ceiling for *request-supplied* timeout limits: a per-request `timeout-limits`
override may lower its timeouts freely but can never raise
`totalTaskTimeoutMillis` or `progressTimeoutMillis` above this value (values
over the cap are clamped with a warning). Limits set in the server's own
`parse-context` are trusted and not subject to this cap.
+|Ceiling for *request-supplied* timeout limits: a per-request `timeout-limits`
override may lower its timeouts freely but can never raise
`totalTaskTimeoutMillis` or `progressTimeoutMillis` above this value (values
over the cap are clamped with a warning). Limits set in the server's own
`parse-context` — or in a preset a request selects — are trusted and not
subject to this cap.
|`maxWaitForClientMillis`
|`60000`
diff --git a/docs/modules/ROOT/pages/pipes/plugins/json.adoc
b/docs/modules/ROOT/pages/pipes/plugins/json.adoc
index 62301adc8f..52b1ebba9d 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/json.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/json.adoc
@@ -57,6 +57,6 @@ Unlike the other iterators, this one takes no `fetcherId` /
`emitterId`: each li
== Notes
* The file format is JSON-lines (also called NDJSON) — one valid JSON object
per line, no surrounding array brackets.
-* Each line must be a serialized `FetchEmitTuple`: `id`, `fetcher`,
`fetchKey`, `emitter`, `emitKey`, and optionally `fetchRangeStart`,
`fetchRangeEnd`, `metadata`, `parse-context`, `onParseException`. Any other
+* Each line must be a serialized `FetchEmitTuple`: `id`, `fetcher`,
`fetchKey`, `emitter`, `emitKey`, and optionally `fetchRangeStart`,
`fetchRangeEnd`, `metadata`, `parse-context`, `onParseException`, `preset`. Any
other
field is rejected — the deserializer does not tolerate unknown keys.
* For columnar work items in a CSV, use the xref:pipes/plugins/csv.adoc[CSV
iterator] instead.
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 4c0048228a..3ad3f58cc1 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -105,6 +105,62 @@ WARNING: Enable this only behind network controls
(firewalls, private subnets),
or xref:using-tika/server/tls.adoc[2-way TLS authentication]. Per-request
configuration lets
callers change how documents are parsed, widening what anyone who can reach
the server can do.
+=== Presets — named configuration without `/config`
+
+A *preset* is a named, vetted parse-context fragment: parser and component
configurations keyed
+by friendly name, defined once in the server config (or shipped with Tika) and
selected whole by
+name. Callers apply one by inserting `preset/{name}` directly after the
resource root:
+`/tika/preset/{name}[/text|/html|/xml|/md|/json[/{handlerType}]]`,
+`/rmeta/preset/{name}[/{handlerType}]`, `/unpack/preset/{name}[/all]`.
+
+[source,bash]
+----
+curl -T document.pdf http://localhost:9998/tika/preset/my-preset/text
+curl -T document.pdf http://localhost:9998/rmeta/preset/my-preset/text
+curl -T document.pdf http://localhost:9998/unpack/preset/my-preset
+----
+
+Nothing is active unless the top-level `presets` key names it. `true`
activates a preset from
+the classpath *catalog* (definitions shipped with Tika, so their content
tracks the Tika
+version); an object defines a preset in place, replacing any same-named
catalog definition
+wholesale; `false`/`null` is an explicit no-op. Catalog jars can never
activate themselves —
+every active preset is a visible line in the operator's config, and `true`
naming nothing in
+the catalog fails startup:
+
+[source,json]
+----
+{
+ "presets": {
+ "some-catalog-preset": true,
+ "no-ocr": { "pdf-parser": { "ocr": { "strategy": "NO_OCR" } } }
+ }
+}
+----
+
+Presets are deliberately narrow: a request selects exactly one, the preset
routes take no
+`config` part, and a preset never combines with request-supplied configuration
— a variant is
+another preset. Because preset content is operator- or Tika-vetted, the preset
routes do *not*
+require `allowPerRequestConfig`, and the `preset/{name}` path segment gives
network controls an
+addressable surface — a reverse proxy can allow `/rmeta/preset/` while
blocking `/rmeta/config`
+entirely. An unknown preset name answers `404`. Names are letters, digits,
`.`, `_`, `-`
+(max 100 chars) and may not start with `config` (that path fragment gates the
`/config`
+endpoints).
+
+Only the preset's *name* travels with a request: the forked parse worker
resolves the content
+from its own copy of the server config, with the same trust as the config's own
+`parse-context` block. A preset can therefore bind components per-request
`/config` input may
+not (detectors, embedded-document extraction, exception reporting, ...) and
raise timeout
+limits above the per-request clamp. Every active preset resolves at startup,
so a bad preset
+fails the server, not its first request.
+
+Output format on the preset routes: an explicit format segment
(`/tika/preset/{name}/text`)
+always wins; without one, a `ContentHandlerFactory` the preset binds decides,
then the
+config's, then the endpoint default (Markdown).
+
+On the batch surfaces, a `FetchEmitTuple` submitted to `/pipes` or `/async`
may carry a
+top-level `"preset": "name"` field, resolved the same way beneath the tuple's
own
+`parseContext`; an unknown name there answers the `PRESET_NOT_FOUND` status
(HTTP `400`).
+
=== `allowPipes` — the `/pipes` and `/async` endpoints
`/pipes` and `/async` drive process-isolated batch parsing through your
configured fetchers and
diff --git
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
index fbb2b94af9..2e9440112f 100644
---
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
+++
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/FetchEmitTuple.java
@@ -38,6 +38,8 @@ public class FetchEmitTuple implements Serializable {
private final Metadata metadata;
private final ParseContext parseContext;
private final ON_PARSE_EXCEPTION onParseException;
+ // Preset selector only: the server resolves the content from its own
config
+ private final String presetName;
public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey) {
this(id, fetchKey, emitKey, new Metadata());
@@ -52,12 +54,19 @@ public class FetchEmitTuple implements Serializable {
public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey,
Metadata metadata, ParseContext parseContext,
ON_PARSE_EXCEPTION onParseException) {
+ this(id, fetchKey, emitKey, metadata, parseContext, onParseException,
null);
+ }
+
+ /** @since Apache Tika 4.1.0 */
+ public FetchEmitTuple(String id, FetchKey fetchKey, EmitKey emitKey,
Metadata metadata, ParseContext parseContext,
+ ON_PARSE_EXCEPTION onParseException, String
presetName) {
this.id = id;
this.fetchKey = fetchKey;
this.emitKey = emitKey;
this.metadata = metadata;
this.parseContext = parseContext;
this.onParseException = onParseException;
+ this.presetName = presetName;
}
public String getId() {
@@ -86,6 +95,15 @@ public class FetchEmitTuple implements Serializable {
return onParseException;
}
+ /**
+ * The selected preset's name, or null for none.
+ *
+ * @since Apache Tika 4.1.0
+ */
+ public String getPresetName() {
+ return presetName;
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -98,7 +116,8 @@ public class FetchEmitTuple implements Serializable {
FetchEmitTuple that = (FetchEmitTuple) o;
return Objects.equals(id, that.id) && Objects.equals(fetchKey,
that.fetchKey) && Objects.equals(emitKey, that.emitKey)
&& Objects.equals(metadata, that.metadata) &&
- Objects.equals(parseContext, that.parseContext) &&
onParseException == that.onParseException;
+ Objects.equals(parseContext, that.parseContext) &&
onParseException == that.onParseException &&
+ Objects.equals(presetName, that.presetName);
}
@Override
@@ -109,6 +128,7 @@ public class FetchEmitTuple implements Serializable {
result = 31 * result + Objects.hashCode(metadata);
result = 31 * result + Objects.hashCode(parseContext);
result = 31 * result + Objects.hashCode(onParseException);
+ result = 31 * result + Objects.hashCode(presetName);
return result;
}
@@ -116,6 +136,6 @@ public class FetchEmitTuple implements Serializable {
public String toString() {
return "FetchEmitTuple{" + "id='" + id + '\'' + ", fetchKey=" +
fetchKey + ", emitKey=" + emitKey +
", metadata=" + metadata + ", parseContext=" + parseContext +
- ", onParseException=" + onParseException + '}';
+ ", onParseException=" + onParseException + ", presetName='" +
presetName + "'}";
}
}
diff --git
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
index fabbcd25ca..adf844b4a6 100644
---
a/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
+++
b/tika-pipes/tika-pipes-api/src/main/java/org/apache/tika/pipes/api/PipesResult.java
@@ -66,6 +66,7 @@ public record PipesResult(RESULT_STATUS status, EmitData
emitData, String messag
EMIT_EXCEPTION(CATEGORY.TASK_EXCEPTION),
FETCHER_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
EMITTER_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
+ PRESET_NOT_FOUND(CATEGORY.TASK_EXCEPTION),
PAYLOAD_LIMIT_EXCEEDED(CATEGORY.TASK_EXCEPTION),
// Process crashes - forked process died, auto-restart
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
index 0b656dda94..90df40149f 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleDeserializer.java
@@ -26,6 +26,7 @@ import static
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.
import static
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.ID;
import static
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.METADATA_KEY;
import static
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.ON_PARSE_EXCEPTION;
+import static
org.apache.tika.pipes.core.serialization.FetchEmitTupleSerializer.PRESET;
import static
org.apache.tika.serialization.serdes.ParseContextSerializer.PARSE_CONTEXT;
import java.io.IOException;
@@ -40,6 +41,7 @@ import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.tika.config.loader.PresetRegistry;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.pipes.api.ComponentIds;
@@ -55,7 +57,7 @@ public class FetchEmitTupleDeserializer extends
JsonDeserializer<FetchEmitTuple>
private static final Set<String> KNOWN_KEYS = Set.of(
ID, FETCHER, FETCH_KEY, EMITTER, EMIT_KEY, FETCH_RANGE_START,
FETCH_RANGE_END,
- METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION);
+ METADATA_KEY, PARSE_CONTEXT, ON_PARSE_EXCEPTION, PRESET);
private final boolean restricted;
@@ -114,10 +116,11 @@ public class FetchEmitTupleDeserializer extends
JsonDeserializer<FetchEmitTuple>
ParseContext parseContext = parseContextNode == null ? new
ParseContext()
: ParseContextDeserializer.readParseContext(parseContextNode,
true);
FetchEmitTuple.ON_PARSE_EXCEPTION onParseException =
readOnParseException(root);
+ String presetName = readPresetName(root);
return new FetchEmitTuple(id, new FetchKey(fetcherId, fetchKey,
fetchRangeStart, fetchRangeEnd),
new EmitKey(emitterName, emitKey), metadata, parseContext,
- onParseException);
+ onParseException, presetName);
}
/**
@@ -147,6 +150,16 @@ public class FetchEmitTupleDeserializer extends
JsonDeserializer<FetchEmitTuple>
}
}
+ // A preset name is only a selector; the shared syntax rule also bounds
its length.
+ private static String readPresetName(JsonNode root) throws IOException {
+ String presetName = readVal(PRESET, root, null, false);
+ if (presetName != null && !PresetRegistry.isValidName(presetName)) {
+ throw new IOException("invalid preset name (letters, digits, '.',
'_', '-'; " +
+ "max 100 chars; may not start with 'config')");
+ }
+ return presetName;
+ }
+
private static FetchEmitTuple.ON_PARSE_EXCEPTION
readOnParseException(JsonNode root) throws IOException {
JsonNode onParseExNode = root.get(ON_PARSE_EXCEPTION);
if (onParseExNode == null) {
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
index 608ab15f37..9667aa22c8 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/FetchEmitTupleSerializer.java
@@ -40,6 +40,7 @@ public class FetchEmitTupleSerializer extends
JsonSerializer<FetchEmitTuple> {
public static final String EMIT_KEY = "emitKey";
public static final String METADATA_KEY = "metadata";
public static final String ON_PARSE_EXCEPTION = "onParseException";
+ public static final String PRESET = "preset";
public void serialize(FetchEmitTuple t, JsonGenerator jsonGenerator,
SerializerProvider serializerProvider) throws IOException {
@@ -59,6 +60,9 @@ public class FetchEmitTupleSerializer extends
JsonSerializer<FetchEmitTuple> {
jsonGenerator.writeObjectField(METADATA_KEY, t.getMetadata());
}
jsonGenerator.writeStringField(ON_PARSE_EXCEPTION,
t.getOnParseException().name().toLowerCase(Locale.US));
+ if (t.getPresetName() != null) {
+ jsonGenerator.writeStringField(PRESET, t.getPresetName());
+ }
ParseContext parseContext = t.getParseContext();
// Tailored: ParseContextSerializer's generic refusal suggests
registering the
// component -- for InlineBytes, exactly the forbidden fix.
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
index 15745cb41a..4a6e41887b 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/serialization/PipesRequest.java
@@ -60,7 +60,7 @@ public final class PipesRequest {
copy.copyFrom(ctx);
copy.set(InlineBytes.class, null);
FetchEmitTuple stripped = new FetchEmitTuple(t.getId(),
t.getFetchKey(), t.getEmitKey(),
- t.getMetadata(), copy, t.getOnParseException());
+ t.getMetadata(), copy, t.getOnParseException(),
t.getPresetName());
return new PipesRequest(stripped, inline.getBytes());
}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
index a211829b15..8b16047e33 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
@@ -216,7 +216,8 @@ public class ConnectionHandler implements Runnable,
Closeable {
ParseContext mergedContext = null;
try {
long ctxStart = System.nanoTime();
- mergedContext =
resources.createMergedParseContext(fetchEmitTuple.getParseContext());
+ mergedContext = resources.createMergedParseContext(
+ fetchEmitTuple.getParseContext(),
fetchEmitTuple.getPresetName());
ParseContextUtils.resolveAll(mergedContext,
getClass().getClassLoader());
ServerProtocolIO.validateParseContext(mergedContext);
ServerProtocolIO.clampRequestTimeoutLimits(
@@ -239,6 +240,11 @@ public class ConnectionHandler implements Runnable,
Closeable {
loopUntilDone(fetchEmitTuple, mergedContext,
intermediateResult, countDownLatch, parseTimeout);
logTiming(fetchEmitTuple.getId());
+ } catch (PresetNotFoundException e) {
+ // caller error, not a server fault: answer it and
keep serving
+ LOG.warn("handlerId={}: id={}: {}", handlerId,
fetchEmitTuple.getId(), e.getMessage());
+ protocolIO.writeFinished(new PipesResult(
+
PipesResult.RESULT_STATUS.PRESET_NOT_FOUND, e.getMessage()));
} catch (TikaConfigException e) {
LOG.error("handlerId={}: config error processing
request", handlerId, e);
handleCrash(PipesMessageType.UNSPECIFIED_CRASH,
fetchEmitTuple.getId(), e);
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index a8a0a2d277..df8fbb5816 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -49,6 +49,7 @@ import org.xml.sax.SAXException;
import org.apache.tika.config.ExceptionReporting;
import org.apache.tika.config.ParseTimeout;
import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.config.loader.PresetRegistry;
import org.apache.tika.config.loader.TikaJsonConfig;
import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.detect.Detector;
@@ -190,6 +191,7 @@ public class PipesServer implements AutoCloseable {
private RecursiveParserWrapper rMetaParser;
private FetcherManager fetcherManager;
private EmitterManager emitterManager;
+ private PresetRegistry presetRegistry;
private ConfigStore configStore;
private final ExecutorService executorService =
Executors.newSingleThreadExecutor();
private final ExecutorCompletionService<PipesResult>
executorCompletionService = new ExecutorCompletionService<>(executorService);
@@ -462,7 +464,8 @@ public class PipesServer implements AutoCloseable {
ParseTimeout parseTimeout;
long ctxStart = System.nanoTime();
try {
- mergedContext =
createMergedParseContext(fetchEmitTuple.getParseContext());
+ mergedContext = createMergedParseContext(
+ fetchEmitTuple.getParseContext(),
fetchEmitTuple.getPresetName());
ParseContextUtils.resolveAll(mergedContext,
getClass().getClassLoader());
ServerProtocolIO.validateParseContext(mergedContext);
ServerProtocolIO.clampRequestTimeoutLimits(
@@ -476,6 +479,12 @@ public class PipesServer implements AutoCloseable {
// sees this instance rather than racing to
install its own.
parseTimeout =
ParseTimeout.getOrCreate(mergedContext);
tCtxMergeNanos = System.nanoTime() - ctxStart;
+ } catch (PresetNotFoundException e) {
+ // caller error, not a server fault: answer it and
keep serving
+ LOG.warn("id={}: {}", fetchEmitTuple.getId(),
e.getMessage());
+ writeFinished(new PipesResult(
+
PipesResult.RESULT_STATUS.PRESET_NOT_FOUND, e.getMessage()));
+ break;
} catch (Exception e) {
// write the reason to the client instead of a
bare exit code
handleCrash(PipesMessageType.UNSPECIFIED_CRASH,
fetchEmitTuple.getId(), e);
@@ -812,7 +821,8 @@ public class PipesServer implements AutoCloseable {
this.autoDetectParser = (AutoDetectParser)
tikaLoader.loadAutoDetectParser();
this.detector = this.autoDetectParser.getDetector();
this.rMetaParser = new RecursiveParserWrapper(autoDetectParser);
-
+ // fails startup on an unresolvable preset, mirroring the front-end's
own load
+ this.presetRegistry = PresetRegistry.load(tikaJsonConfig,
tikaLoader.getClassLoader());
}
/**
@@ -822,9 +832,11 @@ public class PipesServer implements AutoCloseable {
* Creates a fresh context each time to avoid shared state between
requests.
*
* @param requestContext the ParseContext from FetchEmitTuple
- * @return a new ParseContext with defaults + request overrides
+ * @param presetName name of the preset to overlay at config-tier trust,
or null
+ * @return a new ParseContext with defaults + preset + request overrides
*/
- private ParseContext createMergedParseContext(ParseContext requestContext)
throws TikaConfigException {
+ private ParseContext createMergedParseContext(ParseContext requestContext,
String presetName)
+ throws TikaConfigException {
// Create fresh context with defaults from tika-config (e.g.,
DigesterFactory)
ParseContext mergedContext = tikaLoader.loadParseContext();
// EmbeddedDocumentExtractor is deliberately left unset here: setting
a default (even
@@ -833,12 +845,31 @@ public class PipesServer implements AutoCloseable {
// no-ops whenever one is already bound), silently disabling embedded
content
// extraction for every non-UNPACK parse mode. UNPACK mode sets its own
// EmbeddedDocumentExtractor + UnpackedByteCount in PipesWorker's
UNPACK-mode setup.
- // Request-level values override config defaults
+ mergePreset(presetRegistry, presetName, mergedContext);
+ // Request-level values override config defaults and the preset
mergedContext.copyFrom(requestContext);
seedCacheMemoryBudget(mergedContext);
return mergedContext;
}
+ /**
+ * Overlays the named preset, resolved from this server's own config at
config-tier
+ * trust; only the name arrived on the wire. The caller's untrusted delta
is copied
+ * on top afterwards and stays subject to wire screening and timeout
clamping.
+ */
+ static void mergePreset(PresetRegistry registry, String presetName,
ParseContext merged)
+ throws TikaConfigException {
+ if (presetName == null) {
+ return;
+ }
+ ParseContext presetContext = registry.newParseContext(presetName);
+ if (presetContext == null) {
+ throw new PresetNotFoundException(
+ "No preset named '" + presetName + "' is active in this
server's config");
+ }
+ merged.copyFrom(presetContext);
+ }
+
private ConfigStore createConfigStore(PipesConfig pipesConfig,
TikaPluginManager tikaPluginManager) throws TikaException {
String configStoreType = pipesConfig.getConfigStoreType();
String configStoreParams = pipesConfig.getConfigStoreParams();
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
new file mode 100644
index 0000000000..6d03da7b0f
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PresetNotFoundException.java
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core.server;
+
+import org.apache.tika.exception.TikaConfigException;
+
+/**
+ * A request named a preset this server's config does not activate: a caller
error,
+ * answered with a {@code PRESET_NOT_FOUND} result rather than the crash path.
+ *
+ * @since Apache Tika 4.1.0
+ */
+public class PresetNotFoundException extends TikaConfigException {
+
+ public PresetNotFoundException(String msg) {
+ super(msg);
+ }
+}
diff --git
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
index 21371d2ca9..5c97818673 100644
---
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
+++
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
@@ -21,6 +21,7 @@ import java.io.IOException;
import org.xml.sax.SAXException;
import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.config.loader.PresetRegistry;
import org.apache.tika.config.loader.TikaJsonConfig;
import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.detect.Detector;
@@ -66,6 +67,7 @@ public class SharedServerResources {
private final EmitStrategy emitStrategy;
private final ConfigStore configStore;
private final ExceptionReporting exceptionReporting;
+ private final PresetRegistry presetRegistry;
private SharedServerResources(TikaLoader tikaLoader, PipesConfig
pipesConfig,
AutoDetectParser autoDetectParser, Detector
detector,
@@ -74,7 +76,8 @@ public class SharedServerResources {
ContentHandlerFactory
defaultContentHandlerFactory,
MetadataWriteLimiterFactory
defaultMetadataWriteLimiterFactory,
EmitStrategy emitStrategy, ConfigStore
configStore,
- ExceptionReporting exceptionReporting) {
+ ExceptionReporting exceptionReporting,
+ PresetRegistry presetRegistry) {
this.tikaLoader = tikaLoader;
this.pipesConfig = pipesConfig;
this.autoDetectParser = autoDetectParser;
@@ -88,6 +91,7 @@ public class SharedServerResources {
this.emitStrategy = emitStrategy;
this.configStore = configStore;
this.exceptionReporting = exceptionReporting;
+ this.presetRegistry = presetRegistry;
}
/**
@@ -127,10 +131,14 @@ public class SharedServerResources {
EmitStrategy emitStrategy = pipesConfig.getEmitStrategy().getType();
+ // fails startup on an unresolvable preset, mirroring the front-end's
own load
+ PresetRegistry presetRegistry =
+ PresetRegistry.load(tikaJsonConfig,
tikaLoader.getClassLoader());
+
return new SharedServerResources(tikaLoader, pipesConfig,
autoDetectParser, detector,
rMetaParser, fetcherManager, emitterManager, metadataFilter,
contentHandlerFactory,
metadataWriteLimiterFactory, emitStrategy, configStore,
- ExceptionReporting.get(configContext));
+ ExceptionReporting.get(configContext), presetRegistry);
}
private static ConfigStore createConfigStore(PipesConfig pipesConfig,
TikaPluginManager tikaPluginManager)
@@ -155,14 +163,17 @@ public class SharedServerResources {
* Creates a merged ParseContext with defaults from tika-config overlaid
with request values.
*
* @param requestContext the ParseContext from FetchEmitTuple
- * @return a new ParseContext with defaults + request overrides
+ * @param presetName name of the preset to overlay at config-tier trust,
or null
+ * @return a new ParseContext with defaults + preset + request overrides
*/
- public ParseContext createMergedParseContext(ParseContext requestContext)
throws TikaConfigException {
+ public ParseContext createMergedParseContext(ParseContext requestContext,
String presetName)
+ throws TikaConfigException {
ParseContext mergedContext = tikaLoader.loadParseContext();
// EmbeddedDocumentExtractor is deliberately left unset here -- see
PipesServer's
// createMergedParseContext for why defaulting it would silently
disable embedded
// content extraction for every non-UNPACK parse mode.
- // Request-level values override config defaults
+ PipesServer.mergePreset(presetRegistry, presetName, mergedContext);
+ // Request-level values override config defaults and the preset
mergedContext.copyFrom(requestContext);
PipesServer.seedCacheMemoryBudget(mergedContext);
return mergedContext;
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
index f029c3e4a0..abba24cb22 100644
---
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/serialization/JsonFetchEmitTupleTest.java
@@ -18,6 +18,8 @@ package org.apache.tika.pipes.core.serialization;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.Reader;
import java.io.StringReader;
@@ -157,4 +159,29 @@ public class JsonFetchEmitTupleTest {
assertEquals(unpackConfig.getSuffixStrategy(),
deserializedConfig.getSuffixStrategy(),
"suffixStrategy should be preserved");
}
+
+ @Test
+ public void testPresetNameRoundTrips() throws Exception {
+ FetchEmitTuple t = new FetchEmitTuple("my_id", new
FetchKey("my_fetcher", "k"),
+ new EmitKey("my_emitter", "e"), new Metadata(), new
ParseContext(),
+ FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT, "ocr-heavy");
+ FetchEmitTuple deserialized =
+ JsonFetchEmitTuple.fromJson(new
StringReader(JsonFetchEmitTuple.toJson(t)));
+ assertEquals("ocr-heavy", deserialized.getPresetName());
+
+ FetchEmitTuple noPreset = new FetchEmitTuple("my_id", new
FetchKey("my_fetcher", "k"),
+ new EmitKey("my_emitter", "e"));
+ assertNull(JsonFetchEmitTuple
+ .fromJson(new
StringReader(JsonFetchEmitTuple.toJson(noPreset)))
+ .getPresetName());
+ }
+
+ @Test
+ public void testInvalidPresetNameRefused() {
+ // the name is only a selector, but it must obey the shared syntax
bound
+ assertThrows(Exception.class, () -> JsonFetchEmitTuple.fromJson(new
StringReader(
+
"{\"id\":\"i\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"preset\":\"../etc\"}")));
+ assertThrows(Exception.class, () -> JsonFetchEmitTuple.fromJson(new
StringReader(
+
"{\"id\":\"i\",\"fetcher\":\"f\",\"fetchKey\":\"k\",\"preset\":\"config-x\"}")));
+ }
}
diff --git
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
new file mode 100644
index 0000000000..6be2dbf8fe
--- /dev/null
+++
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/PresetMergeTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.pipes.core.server;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.TimeoutLimits;
+import org.apache.tika.config.loader.PresetRegistry;
+import org.apache.tika.config.loader.TikaJsonConfig;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.serialization.ParseContextUtils;
+
+/**
+ * Preset content is operator config resolved at config-tier trust in the
worker:
+ * a preset's timeout limits must survive {@code clampRequestTimeoutLimits}
(only
+ * request-supplied limits are clamped), and an unknown name is a task-level
error,
+ * not a crash.
+ */
+public class PresetMergeTest {
+
+ @TempDir
+ Path tmp;
+
+ private PresetRegistry registry(String configJson) throws Exception {
+ Path p = tmp.resolve("config-" + configJson.hashCode() + ".json");
+ Files.writeString(p, configJson);
+ return PresetRegistry.load(TikaJsonConfig.load(p),
getClass().getClassLoader());
+ }
+
+ @Test
+ public void testPresetTimeoutLimitsAreNotClamped() throws Exception {
+ PresetRegistry registry = registry("""
+ {"presets": {"slow-ocr": {"timeout-limits":
{"totalTaskTimeoutMillis": 3600000}}}}
+ """);
+ ParseContext merged = new ParseContext();
+ PipesServer.mergePreset(registry, "slow-ocr", merged);
+ ParseContext requestContext = new ParseContext();
+ merged.copyFrom(requestContext);
+ ParseContextUtils.resolveAll(merged, getClass().getClassLoader());
+
+ // the clamp fires only on request-supplied limits; the preset's ride
at config tier
+ ServerProtocolIO.clampRequestTimeoutLimits(requestContext, merged,
60_000);
+ assertEquals(3600000,
TimeoutLimits.get(merged).getTotalTaskTimeoutMillis());
+ }
+
+ @Test
+ public void testRequestLimitsStillClampedOverPreset() throws Exception {
+ PresetRegistry registry = registry("""
+ {"presets": {"slow-ocr": {"timeout-limits":
{"totalTaskTimeoutMillis": 3600000}}}}
+ """);
+ ParseContext merged = new ParseContext();
+ PipesServer.mergePreset(registry, "slow-ocr", merged);
+ ParseContext requestContext = new ParseContext();
+ requestContext.setJsonConfig("timeout-limits",
+ "{\"totalTaskTimeoutMillis\": 7200000}");
+ merged.copyFrom(requestContext);
+ ParseContextUtils.resolveAll(merged, getClass().getClassLoader());
+
+ ServerProtocolIO.clampRequestTimeoutLimits(requestContext, merged,
60_000);
+ assertEquals(60_000,
TimeoutLimits.get(merged).getTotalTaskTimeoutMillis());
+ }
+
+ @Test
+ public void testUnknownPresetIsTaskLevelError() throws Exception {
+ PresetRegistry registry = registry("{}");
+ assertThrows(PresetNotFoundException.class,
+ () -> PipesServer.mergePreset(registry, "nope", new
ParseContext()));
+ }
+}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
b/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
new file mode 100644
index 0000000000..5d9cc33459
--- /dev/null
+++
b/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
@@ -0,0 +1,259 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.serialization.ParseContextUtils;
+import org.apache.tika.serialization.serdes.ParseContextDeserializer;
+
+/**
+ * Named, vetted parse-context fragments a caller selects whole, by name only
("presets").
+ * A preset is operator config, not caller input: resolved with the same trust
as the
+ * config's own {@code parse-context} block, and fully resolved at load so a
bad preset
+ * fails startup, not its first request.
+ * <p>
+ * Nothing is active unless the config's {@code presets} block names it:
{@code true}
+ * activates the classpath-catalog definition of that name (error if absent),
an object
+ * defines the preset in place (replacing any catalog definition wholesale),
+ * {@code false}/{@code null} is an explicit no-op. Catalog jars can never
activate
+ * themselves. Presets do not compose.
+ * <p>
+ * The catalog is discovered from {@code
META-INF/tika/preset-catalog.properties}
+ * resources (hand-authored, unlike the annotation processor's generated
+ * {@code META-INF/tika/*.idx}), each line {@code
name=/classpath/resource.json};
+ * blank lines and {@code #} comments ignored.
+ * <pre>
+ * "presets": {
+ * "some-catalog-preset": true,
+ * "ocr-heavy": { "pdf-parser": { "ocr": { "strategy":
"OCR_AND_TEXT_EXTRACTION" } } }
+ * }
+ * </pre>
+ *
+ * @since Apache Tika 4.1.0
+ */
+public final class PresetRegistry {
+
+ public static final String CONFIG_KEY = "presets";
+
+ private static final String INDEX_RESOURCE =
"META-INF/tika/preset-catalog.properties";
+
+ // Names ride in URL paths and config keys
+ private static final Pattern NAME =
Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,99}");
+
+ private final Map<String, JsonNode> presets;
+ private final Set<String> withContentHandlerFactory;
+ private final ClassLoader classLoader;
+
+ private PresetRegistry(Map<String, JsonNode> presets, Set<String>
withContentHandlerFactory,
+ ClassLoader classLoader) {
+ this.presets = presets;
+ this.withContentHandlerFactory = withContentHandlerFactory;
+ this.classLoader = classLoader;
+ }
+
+ /**
+ * Builds the active roster from the config's {@code presets} block (see
the class
+ * javadoc for the value semantics), resolving every active preset.
+ *
+ * @param config the loaded config, may be null (empty roster)
+ * @param classLoader for catalog scanning and component resolution; null
for the
+ * thread context loader
+ */
+ public static PresetRegistry load(TikaJsonConfig config, ClassLoader
classLoader)
+ throws TikaConfigException {
+ ClassLoader loader = classLoader != null ? classLoader
+ : Thread.currentThread().getContextClassLoader();
+ Map<String, JsonNode> presets = new LinkedHashMap<>();
+ if (config != null && config.hasKey(CONFIG_KEY)) {
+ JsonNode block = config.getRootNode().get(CONFIG_KEY);
+ if (block == null || !block.isObject()) {
+ throw new TikaConfigException(
+ "'" + CONFIG_KEY + "' must be an object of preset
definitions");
+ }
+ // load the inert catalog only when the config can reference it
+ Map<String, JsonNode> catalog = loadCatalog(loader);
+ Iterator<Map.Entry<String, JsonNode>> fields = block.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> e = fields.next();
+ String name = e.getKey();
+ JsonNode value = e.getValue();
+ if (value.isNull() || (value.isBoolean() &&
!value.asBoolean())) {
+ continue; // explicit no-op
+ } else if (value.isBoolean()) {
+ JsonNode content = catalog.get(name);
+ if (content == null) {
+ throw new TikaConfigException("preset '" + name +
+ "': true activates a catalog preset, but no
catalog " +
+ "on the classpath defines that name");
+ }
+ presets.put(validName(name), content);
+ } else if (value.isObject()) {
+ presets.put(validName(name), value);
+ } else {
+ throw new TikaConfigException("preset '" + name + "' must
be an " +
+ "object, true (activate catalog definition), or
false/null");
+ }
+ }
+ }
+ Set<String> withContentHandlerFactory = new HashSet<>();
+ for (Map.Entry<String, JsonNode> e : presets.entrySet()) {
+ ParseContext resolved = resolve(e.getKey(), e.getValue(), loader);
+ if (resolved.get(ContentHandlerFactory.class) != null) {
+ withContentHandlerFactory.add(e.getKey());
+ }
+ }
+ return new PresetRegistry(presets, withContentHandlerFactory, loader);
+ }
+
+ // Trusted-tier resolution: operator config, so no wire-block screening.
+ private static ParseContext resolve(String name, JsonNode content,
ClassLoader loader)
+ throws TikaConfigException {
+ try {
+ ParseContext context =
ParseContextDeserializer.readParseContext(content, false);
+ ParseContextUtils.resolveAll(context, loader);
+ return context;
+ } catch (IOException | TikaConfigException e) {
+ throw new TikaConfigException(
+ "preset '" + name + "' failed to resolve: " +
e.getMessage(), e);
+ }
+ }
+
+ private static Map<String, JsonNode> loadCatalog(ClassLoader loader)
+ throws TikaConfigException {
+ Map<String, JsonNode> presets = new LinkedHashMap<>();
+ Map<String, URL> sources = new LinkedHashMap<>();
+ // Same JSON dialect as the config itself (comments allowed, duplicate
keys refused)
+ ObjectMapper mapper = TikaObjectMapperFactory.getMapper();
+ try {
+ Enumeration<URL> indexes = loader.getResources(INDEX_RESOURCE);
+ while (indexes.hasMoreElements()) {
+ URL index = indexes.nextElement();
+ String indexContent;
+ try (InputStream is = index.openStream()) {
+ indexContent = new String(is.readAllBytes(),
StandardCharsets.UTF_8);
+ }
+ for (String line : indexContent.split("\n")) {
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ int eq = line.indexOf('=');
+ if (eq <= 0) {
+ throw new TikaConfigException(
+ "bad line in " + index + ": " + line);
+ }
+ String name = validName(line.substring(0, eq).trim());
+ String resource = line.substring(eq + 1).trim();
+ try (InputStream is = loader.getResourceAsStream(
+ stripLeadingSlash(resource))) {
+ if (is == null) {
+ throw new TikaConfigException("preset '" + name +
+ "' names a missing resource: " + resource);
+ }
+ JsonNode content = mapper.readTree(is);
+ if (!content.isObject()) {
+ throw new TikaConfigException("preset '" + name +
+ "' must contain a JSON object: " +
resource);
+ }
+ JsonNode previous = presets.put(name, content);
+ // classpath order is not a config statement: refuse a
silent last-wins
+ if (previous != null && !previous.equals(content)) {
+ throw new TikaConfigException("catalog preset '" +
name +
+ "' is defined with different content by " +
+ sources.get(name) + " and " + index);
+ }
+ sources.put(name, index);
+ }
+ }
+ }
+ } catch (IOException e) {
+ throw new TikaConfigException("failed to load the preset catalog",
e);
+ }
+ return presets;
+ }
+
+ private static String stripLeadingSlash(String resource) {
+ return resource.startsWith("/") ? resource.substring(1) : resource;
+ }
+
+ private static String validName(String name) throws TikaConfigException {
+ if (!isValidName(name)) {
+ throw new TikaConfigException("invalid preset name (letters,
digits, " +
+ "'.', '_', '-'; max 100 chars; may not start with
'config', which is " +
+ "reserved so preset URL routes stay distinct from /config
endpoint " +
+ "gating): '" + name + "'");
+ }
+ return name;
+ }
+
+ /**
+ * Legal preset name: the NAME rule, and not "config"-prefixed
(tika-server gates
+ * {@code /config} endpoints on that path fragment). Public so wire
deserializers
+ * can bound a preset-name field with the same rule.
+ */
+ public static boolean isValidName(String name) {
+ return name != null && NAME.matcher(name).matches()
+ && !name.regionMatches(true, 0, "config", 0, 6);
+ }
+
+ public Set<String> names() {
+ return Collections.unmodifiableSet(presets.keySet());
+ }
+
+ public boolean hasPreset(String name) {
+ return name != null && presets.containsKey(name);
+ }
+
+ /** The preset's {@code parse-context}-shaped JSON, or null for an unknown
name. */
+ public String parseContextJson(String name) {
+ JsonNode node = name == null ? null : presets.get(name);
+ return node == null ? null : node.toString();
+ }
+
+ /**
+ * A fully resolved ParseContext for the preset, or null for an unknown
name.
+ * Fresh per call: callers mutate the result per request.
+ */
+ public ParseContext newParseContext(String name) throws
TikaConfigException {
+ JsonNode content = name == null ? null : presets.get(name);
+ return content == null ? null : resolve(name, content, classLoader);
+ }
+
+ /** True if the preset binds a {@link ContentHandlerFactory} (it then owns
the
+ * output format on routes with no explicit format segment). */
+ public boolean suppliesContentHandlerFactory(String name) {
+ return name != null && withContentHandlerFactory.contains(name);
+ }
+}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
index 04af3fb1c9..fe8b0351b5 100644
---
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
+++
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaJsonConfig.java
@@ -117,6 +117,7 @@ public class TikaJsonConfig {
"translator",
"auto-detect-parser",
"parse-context",
+ "presets",
"server",
"grpc",
diff --git
a/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
b/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
new file mode 100644
index 0000000000..6c545e35ce
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.config.loader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.ExceptionReporting;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.ParseContext;
+
+public class PresetRegistryTest {
+
+ @TempDir
+ Path tmp;
+
+ private TikaJsonConfig config(String json) throws Exception {
+ Path p = tmp.resolve("config-" + json.hashCode() + ".json");
+ Files.writeString(p, json);
+ return TikaJsonConfig.load(p);
+ }
+
+ private PresetRegistry load(String json) throws Exception {
+ return PresetRegistry.load(config(json), getClass().getClassLoader());
+ }
+
+ @Test
+ public void testCatalogPresetInertUntilActivated() throws Exception {
+ // src/test/resources/META-INF/tika/preset-catalog.properties
contributes builtin-sample,
+ // but a catalog jar must never activate itself
+ PresetRegistry registry = PresetRegistry.load(null,
getClass().getClassLoader());
+ assertTrue(registry.names().isEmpty());
+ assertNull(registry.parseContextJson("builtin-sample"));
+
+ assertNull(load("{}").parseContextJson("builtin-sample"));
+ }
+
+ @Test
+ public void testTrueActivatesCatalogDefinition() throws Exception {
+ PresetRegistry registry = load("""
+ {"presets": {"builtin-sample": true}}
+ """);
+ JsonNode content =
+ new
ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
+ assertEquals("TEXT",
content.get("basic-content-handler-factory").get("type").asText());
+ }
+
+ @Test
+ public void testTrueWithoutCatalogDefinitionFailsStartup() {
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"no-such-catalog-entry": true}}
+ """));
+ }
+
+ @Test
+ public void testConfigDefinesPreset() throws Exception {
+ PresetRegistry registry = load("""
+ {"presets": {"my-preset": {"basic-content-handler-factory":
{"type": "XML"}}}}
+ """);
+ JsonNode content = new
ObjectMapper().readTree(registry.parseContextJson("my-preset"));
+ assertEquals("XML",
content.get("basic-content-handler-factory").get("type").asText());
+ }
+
+ @Test
+ public void testConfigOverridesCatalogDefinitionWholesale() throws
Exception {
+ PresetRegistry registry = load("""
+ {"presets": {"builtin-sample": {"embedded-limits":
{"maxDepth": 2}}}}
+ """);
+ JsonNode content =
+ new
ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
+ assertNull(content.get("basic-content-handler-factory"),
+ "an override replaces the whole preset, it does not merge");
+ assertEquals(2,
content.get("embedded-limits").get("maxDepth").asInt());
+ }
+
+ @Test
+ public void testFalseAndNullAreExplicitNoOps() throws Exception {
+ PresetRegistry registry = load("""
+ {"presets": {"builtin-sample": false, "other": null}}
+ """);
+ assertFalse(registry.names().contains("builtin-sample"));
+ assertNull(registry.parseContextJson("builtin-sample"));
+ assertNull(registry.parseContextJson("other"));
+ }
+
+ @Test
+ public void testUnknownPresetIsNull() throws Exception {
+ assertNull(load("{}").parseContextJson("nope"));
+ assertNull(load("{}").parseContextJson(null));
+ }
+
+ @Test
+ public void testInvalidNameRejected() {
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"bad/name": {}}}
+ """));
+ }
+
+ @Test
+ public void testConfigPrefixedNameRejected() {
+ // tika-server gates /config endpoints on the path fragment, so such a
+ // preset would be unreachable there; refuse it at definition time
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"config-fast": {}}}
+ """));
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"CONFIGX": {}}}
+ """));
+ assertTrue(PresetRegistry.isValidName("fast-config"));
+ }
+
+ @Test
+ public void testUnresolvablePresetFailsLoad() {
+ // a known component with malformed content must fail at load, not
first use
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"bad": {"basic-content-handler-factory": {"type":
"NO_SUCH_TYPE"}}}}
+ """));
+ }
+
+ @Test
+ public void testNewParseContextIsTrustedAndFresh() throws Exception {
+ // exception-reporting is wire-blocked for caller-supplied contexts; a
preset is
+ // operator config and must be able to bind it
+ PresetRegistry registry = load("""
+ {"presets": {"reporting": {"exception-reporting":
{"maxLength": 512}}}}
+ """);
+ ParseContext first = registry.newParseContext("reporting");
+ assertTrue(first.get(ExceptionReporting.class) != null);
+ // fresh per call: callers mutate the result per request
+ assertTrue(first != registry.newParseContext("reporting"));
+ assertNull(registry.newParseContext("nope"));
+ assertNull(registry.newParseContext(null));
+ }
+
+ @Test
+ public void testSuppliesContentHandlerFactory() throws Exception {
+ PresetRegistry registry = load("""
+ {"presets": {
+ "with-chf": {"basic-content-handler-factory": {"type":
"XML"}},
+ "without-chf": {"embedded-limits": {"maxDepth": 2}}}}
+ """);
+ assertTrue(registry.suppliesContentHandlerFactory("with-chf"));
+ assertFalse(registry.suppliesContentHandlerFactory("without-chf"));
+ assertFalse(registry.suppliesContentHandlerFactory("nope"));
+ assertFalse(registry.suppliesContentHandlerFactory(null));
+ }
+
+ @Test
+ public void testCatalogNameCollisionAcrossJarsFails() throws Exception {
+ Path dirA = catalogDir("a", "colliding", "{\"embedded-limits\":
{\"maxDepth\": 1}}");
+ Path dirB = catalogDir("b", "colliding", "{\"embedded-limits\":
{\"maxDepth\": 2}}");
+ // parent is the test loader, so component classes still resolve; its
own
+ // catalog contributes only the distinct builtin-sample name
+ try (URLClassLoader loader = new URLClassLoader(
+ new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
+ getClass().getClassLoader())) {
+ assertThrows(TikaConfigException.class, () -> PresetRegistry.load(
+ config("{\"presets\": {\"colliding\": true}}"), loader));
+ }
+ }
+
+ @Test
+ public void testCatalogIdenticalDuplicateTolerated() throws Exception {
+ // the same jar visible twice on a classpath is noise, not a conflict
+ Path dirA = catalogDir("a2", "dup", "{\"embedded-limits\":
{\"maxDepth\": 3}}");
+ Path dirB = catalogDir("b2", "dup", "{\"embedded-limits\":
{\"maxDepth\": 3}}");
+ try (URLClassLoader loader = new URLClassLoader(
+ new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
+ getClass().getClassLoader())) {
+ PresetRegistry registry = PresetRegistry.load(
+ config("{\"presets\": {\"dup\": true}}"), loader);
+ assertTrue(registry.hasPreset("dup"));
+ }
+ }
+
+ private Path catalogDir(String dirName, String presetName, String json)
throws Exception {
+ Path dir = tmp.resolve(dirName);
+ Files.createDirectories(dir.resolve("META-INF/tika"));
+ // resource path unique per dir: identical paths would shadow on the
classpath
+
Files.writeString(dir.resolve("META-INF/tika/preset-catalog.properties"),
+ presetName + "=/presets-" + dirName + "/" + presetName +
".json\n");
+ Files.createDirectories(dir.resolve("presets-" + dirName));
+ Files.writeString(dir.resolve("presets-" + dirName + "/" + presetName
+ ".json"), json);
+ return dir;
+ }
+
+ @Test
+ public void testNonObjectPresetRejected() {
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"bad": "a string"}}
+ """));
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": {"bad": 42}}
+ """));
+ }
+
+ @Test
+ public void testNonObjectPresetsBlockRejected() {
+ assertThrows(TikaConfigException.class, () -> load("""
+ {"presets": ["not", "an", "object"]}
+ """));
+ }
+}
diff --git
a/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties
b/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties
new file mode 100644
index 0000000000..2a6a4d3316
--- /dev/null
+++
b/tika-serialization/src/test/resources/META-INF/tika/preset-catalog.properties
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# test built-in preset
+builtin-sample=/test-presets/builtin-sample.json
diff --git
a/tika-serialization/src/test/resources/test-presets/builtin-sample.json
b/tika-serialization/src/test/resources/test-presets/builtin-sample.json
new file mode 100644
index 0000000000..938e7a78d5
--- /dev/null
+++ b/tika-serialization/src/test/resources/test-presets/builtin-sample.json
@@ -0,0 +1,4 @@
+{
+ // catalog files use the same JSON dialect as the config: comments allowed
+ "basic-content-handler-factory": {"type": "TEXT"}
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
index 967aabbca7..8d886b7a9b 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PipesParsingHelper.java
@@ -238,6 +238,8 @@ public class PipesParsingHelper {
parseContext.set(ContentBytesConfig.class, new
ContentBytesConfig());
}
+ String presetName = liftPresetSelection(parseContext);
+
// This parser is shared with /pipes, whose own default is
EMIT_ALL. No
// emitter is configured for /tika/rmeta/unpack requests
(EmitKey.NO_EMIT
// below) -- results must come back over the socket, so set
PASSBACK_ALL
@@ -249,7 +251,9 @@ public class PipesParsingHelper {
fetchKey,
EmitKey.NO_EMIT,
metadata,
- parseContext
+ parseContext,
+ FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT,
+ presetName
);
// Execute parse via pipes - results will be passed back through
socket
@@ -285,6 +289,16 @@ public class PipesParsingHelper {
}
}
+ // Only the name travels: the worker resolves the preset from its own
config.
+ private static String liftPresetSelection(ParseContext parseContext) {
+ PresetSelection preset = parseContext.get(PresetSelection.class);
+ if (preset == null) {
+ return null;
+ }
+ parseContext.set(PresetSelection.class, null);
+ return preset.name();
+ }
+
/**
* One line per request on {@code org.apache.tika.pipes.timing.server},
microseconds.
* {@code route_us} covers reading the request body and deciding
inline-vs-spool;
@@ -481,7 +495,7 @@ public class PipesParsingHelper {
// The caller named a fetcher/emitter this server does not have.
Nothing failed
// on our side, and retrying the same request will never succeed
-- 500 told
// clients to retry a request that is permanently malformed.
- case FETCHER_NOT_FOUND, EMITTER_NOT_FOUND ->
+ case FETCHER_NOT_FOUND, EMITTER_NOT_FOUND, PRESET_NOT_FOUND ->
Response.Status.BAD_REQUEST;
case PAYLOAD_LIMIT_EXCEEDED ->
Response.Status.REQUEST_ENTITY_TOO_LARGE;
@@ -629,6 +643,8 @@ public class PipesParsingHelper {
// Set parse mode to UNPACK
parseContext.set(ParseMode.class, ParseMode.UNPACK);
+ String presetName = liftPresetSelection(parseContext);
+
// Shared parser (see parse() above) -- PASSBACK_ALL is also
required here
// for correctness: with UNPACK mode, EmitHandler.shouldEmit()
only skips
// re-emitting metadata (already emitted as part of the zip) when
the
@@ -665,7 +681,9 @@ public class PipesParsingHelper {
fetchKey,
emitKey,
metadata,
- parseContext
+ parseContext,
+ FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT,
+ presetName
);
// Execute parse via pipes
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
new file mode 100644
index 0000000000..f1360a73ad
--- /dev/null
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/PresetSelection.java
@@ -0,0 +1,28 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.core.resource;
+
+/**
+ * In-process carrier for a request's selected preset name: rides the request
+ * ParseContext until {@link PipesParsingHelper} lifts it onto the tuple's
preset
+ * field. Never serialized (the wire serializer refuses unregistered entries,
so a
+ * leak fails loudly).
+ *
+ * @since Apache Tika 4.1.0
+ */
+public record PresetSelection(String name) {
+}
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
index 4bd0b95f21..4d8091d85e 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/RecursiveMetadataResource.java
@@ -137,6 +137,45 @@ public class RecursiveMetadataResource {
}
}
+ /** As the bare {@code /rmeta} endpoint, with the named preset applied. */
+ @PUT
+ @Produces("application/json")
+ @Path("preset/{presetName}")
+ public Response getMetadataWithPresetDefaultHandler(InputStream is,
+ @Context HttpHeaders
httpHeaders,
+
@PathParam("presetName") String presetName)
+ throws Exception {
+ return getMetadataWithPreset(is, httpHeaders, presetName, null);
+ }
+
+ /**
+ * As {@code /rmeta/{handlerType}}, with the named preset's parse-context
+ * fragment applied. Takes no config part -- a preset never combines with
+ * request-supplied configuration.
+ */
+ @PUT
+ @Produces("application/json")
+ @Path("preset/{presetName}/{" + HANDLER_TYPE_PARAM + " : (\\w+)?}")
+ public Response getMetadataWithPreset(InputStream is, @Context HttpHeaders
httpHeaders,
+ @PathParam("presetName") String
presetName,
+ @PathParam(HANDLER_TYPE_PARAM)
String handlerTypeName)
+ throws Exception {
+ ParseContext context = tikaResource.createPresetContext(presetName);
+ // Explicit format segment wins over the preset's own factory; without
one,
+ // parseMetadataWithContext defers preset -> config -> default.
+ if (handlerTypeName != null && !handlerTypeName.isBlank()) {
+ tikaResource.setupContentHandlerFactory(context, handlerTypeName);
+ }
+ Metadata metadata = tikaResource.newRequestMetadata();
+ try (TikaInputStream tis = TikaInputStream.get(is)) {
+ fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+ TikaResource.logRequest(LOG, "/rmeta", metadata);
+ return Response
+ .ok(parseMetadataWithContext(tis, metadata,
handlerTypeName, context))
+ .build();
+ }
+ }
+
private MetadataList parseMetadataWithContext(TikaInputStream tis,
Metadata metadata,
String handlerTypeName,
ParseContext context) throws Exception {
tikaResource.setupContentHandlerFactoryIfNeeded(context,
handlerTypeName);
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
index b955bde571..3b4e9b01d3 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/TikaResource.java
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
+import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
@@ -52,6 +53,7 @@ import org.apache.tika.Tika;
import org.apache.tika.config.ExceptionReporting;
import org.apache.tika.config.JsonConfig;
import org.apache.tika.config.OutputLimits;
+import org.apache.tika.config.loader.PresetRegistry;
import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.io.TikaInputStream;
@@ -94,6 +96,9 @@ public class TikaResource {
private final ExceptionReporting configExceptionReporting;
private final boolean configSuppliesContentHandlerFactory;
+ // Named, vetted parse-context fragments; requests select one whole by name
+ private final PresetRegistry presetRegistry;
+
/**
* @param tikaLoader the Tika loader
* @param serverStatus server status tracker
@@ -113,6 +118,31 @@ public class TikaResource {
this.configExceptionReporting = ExceptionReporting.get(configDefaults);
this.configSuppliesContentHandlerFactory =
configDefaults.get(ContentHandlerFactory.class) != null;
+ try {
+ this.presetRegistry = PresetRegistry.load(tikaLoader.getConfig(),
+ tikaLoader.getClassLoader());
+ } catch (TikaConfigException e) {
+ // config error (including a preset that cannot resolve): fail
startup,
+ // not the first preset request
+ throw new IllegalStateException("Invalid 'presets' configuration",
e);
+ }
+ }
+
+ /**
+ * A request context carrying only the preset selection: the forked worker
resolves
+ * the content from its own copy of this config at config-tier trust, so a
preset is
+ * never treated as caller-supplied wire data (which would screen out
wire-blocked
+ * components and clamp its timeouts).
+ *
+ * @throws NotFoundException if no preset has this name
+ */
+ public ParseContext createPresetContext(String presetName) {
+ if (!presetRegistry.hasPreset(presetName)) {
+ throw new NotFoundException("No such preset: " + presetName);
+ }
+ ParseContext context = createRequestContext();
+ context.set(PresetSelection.class, new PresetSelection(presetName));
+ return context;
}
/**
@@ -477,11 +507,19 @@ public class TikaResource {
* @param handlerTypeName the handler type name
*/
public void setupContentHandlerFactoryIfNeeded(ParseContext context,
String handlerTypeName) {
+ if (context.get(ContentHandlerFactory.class) != null) {
+ return;
+ }
+ // A selected preset that binds its own factory decides the format on
routes with no
+ // explicit format segment; the worker resolves it from the preset at
config tier.
+ PresetSelection preset = context.get(PresetSelection.class);
+ if (preset != null &&
presetRegistry.suppliesContentHandlerFactory(preset.name())) {
+ return;
+ }
// A config-declared factory still takes precedence; it is no longer
visible in the
// request context, so leaving the context untouched lets the worker
resolve it from
// the same config.
- if (context.get(ContentHandlerFactory.class) == null
- && !configSuppliesContentHandlerFactory) {
+ if (!configSuppliesContentHandlerFactory) {
setupContentHandlerFactory(context, handlerTypeName);
}
}
@@ -592,6 +630,123 @@ public class TikaResource {
return putJson(is, httpHeaders, handlerTypeName);
}
+ // ==================== PUT preset endpoints ====================
+
+ // Mirrors of the PUT endpoints above:
/tika/preset/{name}[/text|/html|/xml|/md|
+ // /json[/{handlerType}]]. The preset segment sits directly after the
resource root
+ // so network-layer rules can address /tika/preset/* independently of
/tika/config*.
+ // These routes take no config part; a preset never combines with request
config.
+ // explicitHandlerType: non-null (an explicit format segment) wins over
everything,
+ // including the preset's own factory; null defers preset -> config ->
default.
+
+ private Response putRawPreset(InputStream is, HttpHeaders httpHeaders,
String presetName,
+ String explicitHandlerType) throws
IOException {
+ ParseContext context = createPresetContext(presetName);
+ Metadata metadata = newRequestMetadata();
+ fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+ if (explicitHandlerType != null) {
+ setupContentHandlerFactory(context, explicitHandlerType);
+ }
+ try (TikaInputStream tis = TikaInputStream.get(is)) {
+ return produceRawOutputWithContext(tis, metadata, context,
explicitHandlerType);
+ }
+ }
+
+ private Metadata putJsonPreset(InputStream is, HttpHeaders httpHeaders,
String presetName,
+ String explicitHandlerType) throws
IOException {
+ ParseContext context = createPresetContext(presetName);
+ Metadata metadata = newRequestMetadata();
+ fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+ if (explicitHandlerType != null) {
+ setupContentHandlerFactory(context, explicitHandlerType);
+ }
+ try (TikaInputStream tis = TikaInputStream.get(is)) {
+ return produceJsonWithContext(tis, metadata, context,
explicitHandlerType);
+ }
+ }
+
+ /**
+ * As the bare /tika endpoint, with the named preset applied. A factory
the preset
+ * binds decides the output format here; without one the Markdown default
applies.
+ */
+ @PUT
+ @Consumes("*/*")
+ @Produces("text/plain;charset=UTF-8")
+ @Path("preset/{presetName}")
+ public Response getDefaultWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putRawPreset(is, httpHeaders, presetName, null);
+ }
+
+ /** As /tika/text, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("text/plain;charset=UTF-8")
+ @Path("preset/{presetName}/text")
+ public Response getTextWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putRawPreset(is, httpHeaders, presetName, "body");
+ }
+
+ /** As /tika/html, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("text/html;charset=UTF-8")
+ @Path("preset/{presetName}/html")
+ public Response getHtmlWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putRawPreset(is, httpHeaders, presetName, "html");
+ }
+
+ /** As /tika/xml, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("text/xml;charset=UTF-8")
+ @Path("preset/{presetName}/xml")
+ public Response getXmlWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putRawPreset(is, httpHeaders, presetName, "xml");
+ }
+
+ /** As /tika/md, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("text/plain;charset=UTF-8")
+ @Path("preset/{presetName}/md")
+ public Response getMarkdownWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putRawPreset(is, httpHeaders, presetName, "md");
+ }
+
+ /** As /tika/json, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("application/json")
+ @Path("preset/{presetName}/json")
+ public Metadata getJsonDefaultWithPreset(final InputStream is,
+ @Context HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName)
+ throws IOException {
+ return putJsonPreset(is, httpHeaders, presetName, null);
+ }
+
+ /** As /tika/json/{handlerType}, with the named preset applied. */
+ @PUT
+ @Consumes("*/*")
+ @Produces("application/json")
+ @Path("preset/{presetName}/json/{" + HANDLER_TYPE_PARAM + "}")
+ public Metadata getJsonWithPreset(final InputStream is, @Context
HttpHeaders httpHeaders,
+ @PathParam("presetName") String
presetName,
+ @PathParam(HANDLER_TYPE_PARAM) String
handlerTypeName)
+ throws IOException {
+ return putJsonPreset(is, httpHeaders, presetName, handlerTypeName);
+ }
+
// ==================== POST endpoints (multipart with optional config)
====================
// All /tika/config* endpoints take a required "file" part and an optional
"config"
diff --git
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
index 1180a62b90..f47243795e 100644
---
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
+++
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/resource/UnpackerResource.java
@@ -145,10 +145,21 @@ public class UnpackerResource {
* @param info URI info
* @return streaming zip response
*/
+ // The wildcard id would otherwise absorb a transposed preset URL
(/unpack/all/preset/x)
+ // and silently run with no preset applied.
+ private static void rejectPresetInWildcard(UriInfo info) {
+ String id = info.getPathParameters().getFirst("id");
+ if (id != null && (id.equals("/preset") || id.startsWith("/preset/")))
{
+ throw new jakarta.ws.rs.NotFoundException(
+ "preset routes are PUT /unpack/preset/{name}[/all]");
+ }
+ }
+
@jakarta.ws.rs.Path("/{id:(/.*)?}")
@PUT
@Produces("application/zip")
public Response unpack(InputStream is, @Context HttpHeaders httpHeaders,
@Context UriInfo info) throws Exception {
+ rejectPresetInWildcard(info);
ParseContext pc = tikaResource.createRequestContext();
Metadata metadata = tikaResource.newRequestMetadata();
try (TikaInputStream tis = TikaInputStream.get(is)) {
@@ -172,6 +183,7 @@ public class UnpackerResource {
@Consumes("multipart/form-data")
@Produces("application/zip")
public Response unpackWithConfig(List<Attachment> attachments, @Context
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
+ rejectPresetInWildcard(info);
ParseContext pc = tikaResource.createRequestContext();
Metadata metadata = tikaResource.newRequestMetadata();
try (TikaInputStream tis =
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
@@ -193,6 +205,7 @@ public class UnpackerResource {
@PUT
@Produces("application/zip")
public Response unpackAll(InputStream is, @Context HttpHeaders
httpHeaders, @Context UriInfo info) throws Exception {
+ rejectPresetInWildcard(info);
ParseContext pc = tikaResource.createRequestContext();
Metadata metadata = tikaResource.newRequestMetadata();
try (TikaInputStream tis = TikaInputStream.get(is)) {
@@ -216,6 +229,7 @@ public class UnpackerResource {
@Consumes("multipart/form-data")
@Produces("application/zip")
public Response unpackAllWithConfig(List<Attachment> attachments, @Context
HttpHeaders httpHeaders, @Context UriInfo info) throws Exception {
+ rejectPresetInWildcard(info);
ParseContext pc = tikaResource.createRequestContext();
Metadata metadata = tikaResource.newRequestMetadata();
try (TikaInputStream tis =
tikaResource.setupMultipartConfig(attachments, metadata, pc)) {
@@ -224,6 +238,43 @@ public class UnpackerResource {
}
}
+ /**
+ * As {@code /unpack}, with the named preset's parse-context fragment
applied.
+ * Takes no config part -- a preset never combines with request
configuration.
+ */
+ @jakarta.ws.rs.Path("/preset/{presetName}")
+ @PUT
+ @Produces("application/zip")
+ public Response unpackWithPreset(InputStream is, @Context HttpHeaders
httpHeaders,
+ @jakarta.ws.rs.PathParam("presetName")
String presetName)
+ throws Exception {
+ ParseContext pc = tikaResource.createPresetContext(presetName);
+ Metadata metadata = tikaResource.newRequestMetadata();
+ try (TikaInputStream tis = TikaInputStream.get(is)) {
+ fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+ TikaResource.logRequest(LOG, "/unpack", metadata);
+ return doUnpack(tis, metadata, pc, false);
+ }
+ }
+
+ /**
+ * As {@code /unpack/all}, with the named preset's parse-context fragment
applied.
+ */
+ @jakarta.ws.rs.Path("/preset/{presetName}/all")
+ @PUT
+ @Produces("application/zip")
+ public Response unpackAllWithPreset(InputStream is, @Context HttpHeaders
httpHeaders,
+ @jakarta.ws.rs.PathParam("presetName")
String presetName)
+ throws Exception {
+ ParseContext pc = tikaResource.createPresetContext(presetName);
+ Metadata metadata = tikaResource.newRequestMetadata();
+ try (TikaInputStream tis = TikaInputStream.get(is)) {
+ fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+ TikaResource.logRequest(LOG, "/unpack/all", metadata);
+ return doUnpack(tis, metadata, pc, true);
+ }
+ }
+
/**
* Core unpack logic using pipes-based parsing.
* The child process creates the zip file, and we stream it directly back.
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
index b7a158363f..155b7dcc87 100644
---
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/CXFTestBase.java
@@ -357,6 +357,12 @@ public abstract class CXFTestBase {
if (metadataFilters != null && !metadataFilters.isEmpty()) {
root.set("metadata-filters", metadataFilters);
}
+ // The worker resolves preset names from its own config, so
presets must be
+ // visible there just like the parse-context defaults above.
+ JsonNode presets = tikaConfig.get("presets");
+ if (presets != null && !presets.isEmpty()) {
+ root.set("presets", presets);
+ }
} catch (Exception e) {
LOG.debug("Could not carry config into the worker config: {}",
e.getMessage());
}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
new file mode 100644
index 0000000000..dc703751e0
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
@@ -0,0 +1,192 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.core;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.resource.TikaResource;
+import org.apache.tika.server.core.resource.UnpackerResource;
+import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
+
+/**
+ * Routing and behavior of the {@code /preset/{name}} endpoints: the preset
+ * segment sits directly after the resource root, must win over the wildcard
+ * routes ({@code /rmeta/{handlerType}}, {@code /unpack/{id}}), and an unknown
+ * name answers 404.
+ */
+public class PresetEndpointsTest extends CXFTestBase {
+
+ private static final String HELLO_WORLD =
"test-documents/mock/hello_world.xml";
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @Override
+ protected InputStream getTikaConfigInputStream() throws
java.io.IOException {
+ ObjectNode config = (ObjectNode) MAPPER.readTree(BASIC_CONFIG);
+ ObjectNode presets = config.putObject("presets");
+ presets.putObject("xml-content")
+ .putObject("basic-content-handler-factory").put("type", "XML");
+ // exception-reporting is wire-blocked for caller-supplied config; a
preset is
+ // operator config and must be able to bind it (resolved worker-side)
+ presets.putObject("reporting")
+ .putObject("exception-reporting").put("maxLength", 512);
+ return new ByteArrayInputStream(
+ MAPPER.writeValueAsString(config).getBytes(UTF_8));
+ }
+
+ @Override
+ protected void setUpResources(JAXRSServerFactoryBean sf) {
+ sf.setResourceClasses(RecursiveMetadataResource.class,
UnpackerResource.class,
+ TikaResource.class);
+ sf.setResourceProvider(RecursiveMetadataResource.class,
+ new SingletonResourceProvider(new
RecursiveMetadataResource(tikaResource)));
+ sf.setResourceProvider(UnpackerResource.class,
+ new SingletonResourceProvider(new
UnpackerResource(tikaResource)));
+ sf.setResourceProvider(TikaResource.class,
+ new SingletonResourceProvider(tikaResource));
+ }
+
+ @Override
+ protected void setUpProviders(JAXRSServerFactoryBean sf) {
+ List<Object> providers = new ArrayList<>();
+ providers.add(new MetadataListMessageBodyWriter());
+ sf.setProviders(providers);
+ }
+
+ @Test
+ public void testRmetaPresetApplies() throws Exception {
+ Response response = WebClient
+ .create(endPoint + "/rmeta/preset/xml-content")
+ .accept("application/json")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(200, response.getStatus());
+ Reader reader = new InputStreamReader((InputStream)
response.getEntity(), UTF_8);
+ List<Metadata> metadataList = JsonMetadataList.fromJson(reader);
+ Metadata metadata = metadataList.get(0);
+ assertEquals("Nikolai Lobachevsky", metadata.get("author"));
+ // markup in the content proves the preset's XML handler replaced the
+ // markdown default, which emits plain "hello world"
+ String content = metadata.get(TikaCoreProperties.TIKA_CONTENT);
+ assertContains("<body><p>hello world</p>", content);
+ }
+
+ @Test
+ public void testRmetaUnknownPresetIs404() throws Exception {
+ Response response = WebClient
+ .create(endPoint + "/rmeta/preset/nope")
+ .accept("application/json")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(404, response.getStatus());
+ }
+
+ @Test
+ public void testUnpackPresetRouteBeatsWildcard() throws Exception {
+ // /unpack/{id:(/.*)?} is a catch-all; the preset literal must win, so
an
+ // unknown preset answers 404 from the preset route, not the wildcard
+ Response response = WebClient
+ .create(endPoint + "/unpack/preset/nope")
+ .accept("application/zip")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(404, response.getStatus());
+ }
+
+ @Test
+ public void testTikaPresetApplies() throws Exception {
+ Response response = WebClient
+ .create(endPoint + "/tika/preset/xml-content")
+ .accept("text/plain")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(200, response.getStatus());
+ String content = getStringFromInputStream((InputStream)
response.getEntity());
+ // the preset's XML content handler wins over the endpoint's markdown
default
+ assertContains("<body><p>hello world</p>", content);
+ }
+
+ @Test
+ public void testExplicitFormatSegmentWinsOverPresetFactory() throws
Exception {
+ // /tika/preset/xml-content/text: the URL's own format segment beats
the XML
+ // factory the preset binds -- it rides the request delta, which the
worker
+ // overlays on top of the preset
+ Response response = WebClient
+ .create(endPoint + "/tika/preset/xml-content/text")
+ .accept("text/plain")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(200, response.getStatus());
+ String content = getStringFromInputStream((InputStream)
response.getEntity());
+ assertContains("hello world", content);
+ assertFalse(content.contains("<body>"), "explicit /text segment must
win: " + content);
+ }
+
+ @Test
+ public void testWireBlockedComponentWorksInPreset() throws Exception {
+ // previously this 500'd: the preset was pushed through the untrusted
wire
+ // deserializer, which refuses exception-reporting
+ Response response = WebClient
+ .create(endPoint + "/tika/preset/reporting")
+ .accept("text/plain")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(200, response.getStatus());
+ assertContains("hello world",
+ getStringFromInputStream((InputStream) response.getEntity()));
+ }
+
+ @Test
+ public void testTransposedUnpackPresetUrlIs404() throws Exception {
+ // /unpack/all/preset/{name} would otherwise fall into the /all{id}
wildcard
+ // and run with no preset applied -- a silent wrong-config success
+ Response response = WebClient
+ .create(endPoint + "/unpack/all/preset/xml-content")
+ .accept("application/zip")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(404, response.getStatus());
+
+ response = WebClient
+ .create(endPoint + "/unpack/preset")
+ .accept("application/zip")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(404, response.getStatus());
+ }
+
+ @Test
+ public void testTikaUnknownPresetIs404() throws Exception {
+ Response response = WebClient
+ .create(endPoint + "/tika/preset/nope")
+ .accept("text/plain")
+ .put(ClassLoader.getSystemResourceAsStream(HELLO_WORLD));
+ assertEquals(404, response.getStatus());
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
new file mode 100644
index 0000000000..61363e86d7
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.server.core.resource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import jakarta.ws.rs.NotFoundException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.sax.ContentHandlerFactory;
+import org.apache.tika.server.core.ServerStatus;
+
+public class TikaResourcePresetTest {
+
+ private static final String CONFIG = """
+ {
+ "presets": {
+ "xml-content": {"basic-content-handler-factory": {"type":
"XML"}}
+ }
+ }
+ """;
+
+ @TempDir
+ Path tmp;
+
+ private TikaResource newTikaResource(String configJson, boolean
allowPerRequestConfig)
+ throws Exception {
+ Path configPath = tmp.resolve("tika-config-" + configJson.hashCode() +
".json");
+ Files.writeString(configPath, configJson);
+ return new TikaResource(TikaLoader.load(configPath), new
ServerStatus(), null,
+ allowPerRequestConfig);
+ }
+
+ @Test
+ public void testPresetSelectionRidesRequestContextByNameOnly() throws
Exception {
+ // only the name is recorded: the forked worker resolves the content
from its
+ // own config at config-tier trust, so nothing preset-shaped may enter
the
+ // request (caller-tier) context here
+ ParseContext context =
+ newTikaResource(CONFIG,
true).createPresetContext("xml-content");
+ assertEquals("xml-content", context.get(PresetSelection.class).name());
+ assertNull(context.get(ContentHandlerFactory.class));
+ }
+
+ @Test
+ public void testPresetWorksWithPerRequestConfigDisabled() throws Exception
{
+ // presets are admin/Tika-vetted: selecting one must not require the
+ // free-form per-request-config privilege
+ ParseContext context =
+ newTikaResource(CONFIG,
false).createPresetContext("xml-content");
+ assertEquals("xml-content", context.get(PresetSelection.class).name());
+ }
+
+ @Test
+ public void testUnknownPresetIs404() throws Exception {
+ TikaResource resource = newTikaResource(CONFIG, true);
+ assertThrows(NotFoundException.class, () ->
resource.createPresetContext("nope"));
+ }
+
+ @Test
+ public void testInvalidPresetsConfigFailsStartup() {
+ assertThrows(IllegalStateException.class,
+ () -> newTikaResource("{\"presets\": {\"bad\": \"a
string\"}}", true));
+ }
+
+ @Test
+ public void testUnresolvablePresetFailsStartup() {
+ // preset content is resolved at load: a malformed component fails
startup,
+ // not the first preset request
+ assertThrows(IllegalStateException.class, () -> newTikaResource("""
+ {"presets": {"bad": {"basic-content-handler-factory": {"type":
"NOPE"}}}}
+ """, true));
+ }
+}