This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch TIKA-4856-presets
in repository https://gitbox.apache.org/repos/asf/tika.git

commit 818af49238e65d4fb404ebdb9fa623ea7afea437
Author: tallison <[email protected]>
AuthorDate: Wed Sep 2 15:03:16 2026 -0400

    TIKA-4856: named configuration presets for tika-server
---
 CHANGES.txt                                        |  12 ++
 .../ROOT/pages/using-tika/server/index.adoc        |  39 +++++
 .../apache/tika/config/loader/PresetRegistry.java  | 190 +++++++++++++++++++++
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../tika/config/loader/PresetRegistryTest.java     | 138 +++++++++++++++
 .../src/test/resources/META-INF/tika/presets.idx   |   2 +
 .../resources/test-presets/builtin-sample.json     |   3 +
 .../core/resource/RecursiveMetadataResource.java   |  34 ++++
 .../tika/server/core/resource/TikaResource.java    | 143 ++++++++++++++++
 .../server/core/resource/UnpackerResource.java     |  37 ++++
 .../tika/server/core/PresetEndpointsTest.java      | 142 +++++++++++++++
 .../core/resource/TikaResourcePresetTest.java      |  87 ++++++++++
 12 files changed, 828 insertions(+)

diff --git a/CHANGES.txt b/CHANGES.txt
index 80e898aa72..c6a10a7791 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,17 @@
 Release 4.1.0 - unreleased
 
+   * tika-server: named configuration presets (TIKA-4856). A preset is a
+     vetted parse-context fragment activated in the server config (top-level
+     "presets" key: an object defines one in place, true activates a
+     definition from the classpath catalog so its content tracks the Tika
+     version) and selected whole by inserting preset/{name} after the
+     resource root: /tika/preset/{name}[/text|...],
+     /rmeta/preset/{name}[/{handlerType}], /unpack/preset/{name}[/all].
+     Exactly one preset per request, never combined with a config part, and
+     usable without allowPerRequestConfig -- the preset routes are network-
+     addressable separately from the /config endpoints, and nothing on the
+     classpath can activate a preset by itself.
+
    * Raster previews for the vector thumbnails of Office documents: the new
      poi-metafile-renderer draws EMF and WMF images through POI (a PNG of
      a configurable width; Word's bitmap-in-WMF thumbnails from the bitmap
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 4c0048228a..6651effd64 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -105,6 +105,45 @@ 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:
+
+[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 caller who
+needs a variant asks the operator to define it as another preset. Because the 
content of a
+preset is operator- or Tika-vetted, the preset routes do *not* require 
`allowPerRequestConfig`:
+they are the safe public knob, while free-form `/config` stays the privileged 
one. The
+`preset/{name}` path segment also gives network controls an addressable 
surface — a reverse
+proxy can allow `/rmeta/preset/render-thumbnails` (or all of `/rmeta/preset/`) 
while blocking
+`/rmeta/config` entirely. An unknown preset name answers `404`.
+
 === `allowPipes` — the `/pipes` and `/async` endpoints
 
 `/pipes` and `/async` drive process-isolated batch parsing through your 
configured fetchers and
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..88301659c5
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/PresetRegistry.java
@@ -0,0 +1,190 @@
+/*
+ * 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.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;
+
+/**
+ * Named, vetted parse-context fragments a caller can select whole ("presets").
+ * A preset's content has the shape of a {@code parse-context} block: parser 
and
+ * component configurations keyed by friendly name. A caller references a 
preset
+ * by name only, so the configuration itself stays in Tika and in the server's
+ * config rather than in consuming applications.
+ * <p>
+ * Nothing is active unless the config's {@code presets} block names it: an
+ * entry with value {@code true} activates the catalog definition of that name
+ * (content shipped on the classpath, so it tracks the Tika version); an object
+ * value defines the preset in place (replacing any catalog definition
+ * wholesale); {@code false} or {@code null} is an explicit no-op. Catalog jars
+ * can never activate themselves -- every active preset is a visible line in
+ * the operator's config. Presets do not compose.
+ * <p>
+ * The catalog is discovered from {@code META-INF/tika/presets.idx} resources,
+ * each line {@code name=/classpath/resource.json}. Blank lines and {@code #}
+ * comments are ignored.
+ * <pre>
+ * "presets": {
+ *   "some-catalog-preset": true,
+ *   "ocr-heavy": { "pdf-parser": { "ocr": { "strategy": 
"OCR_AND_TEXT_EXTRACTION" } } }
+ * }
+ * </pre>
+ */
+public final class PresetRegistry {
+
+    public static final String CONFIG_KEY = "presets";
+
+    private static final String INDEX_RESOURCE = "META-INF/tika/presets.idx";
+
+    // 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, String> presets;
+
+    private PresetRegistry(Map<String, String> presets) {
+        this.presets = presets;
+    }
+
+    /**
+     * Builds the active roster from the config's {@code presets} block: only
+     * names it lists are active. {@code true} activates a catalog definition
+     * (startup error if the catalog has no such name); an object defines the
+     * preset in place; {@code false}/{@code null} deactivates explicitly.
+     *
+     * @param config the loaded config, may be null (empty roster)
+     * @param classLoader loader to scan for catalog preset indexes, may be 
null
+     *                    for the thread context loader
+     */
+    public static PresetRegistry load(TikaJsonConfig config, ClassLoader 
classLoader)
+            throws TikaConfigException {
+        Map<String, String> 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
+            ClassLoader loader = classLoader != null ? classLoader
+                    : Thread.currentThread().getContextClassLoader();
+            Map<String, String> 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())) {
+                    presets.remove(name);
+                } else if (value.isBoolean()) {
+                    String 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.toString());
+                } else {
+                    throw new TikaConfigException("preset '" + name + "' must 
be an " +
+                            "object, true (activate catalog definition), or 
false/null");
+                }
+            }
+        }
+        return new PresetRegistry(presets);
+    }
+
+    private static Map<String, String> loadCatalog(ClassLoader loader)
+            throws TikaConfigException {
+        Map<String, String> presets = new LinkedHashMap<>();
+        ObjectMapper mapper = new ObjectMapper();
+        try {
+            Enumeration<URL> indexes = loader.getResources(INDEX_RESOURCE);
+            while (indexes.hasMoreElements()) {
+                URL index = indexes.nextElement();
+                for (String line : new 
String(index.openStream().readAllBytes(),
+                        StandardCharsets.UTF_8).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);
+                        }
+                        presets.put(name, content.toString());
+                    }
+                }
+            }
+        } 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 (!NAME.matcher(name).matches()) {
+            throw new TikaConfigException("invalid preset name (letters, 
digits, " +
+                    "'.', '_', '-'; max 100 chars): '" + name + "'");
+        }
+        return name;
+    }
+
+    public Set<String> names() {
+        return Collections.unmodifiableSet(presets.keySet());
+    }
+
+    /**
+     * The preset's content -- a {@code parse-context}-shaped JSON object of
+     * component configurations -- or null if no preset has this name.
+     */
+    public String parseContextJson(String name) {
+        return name == null ? null : presets.get(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 d15815b4ac..12bf95d3ec 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
@@ -116,6 +116,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..14d93eb5fc
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/PresetRegistryTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.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.exception.TikaConfigException;
+
+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/presets.idx 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 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/presets.idx 
b/tika-serialization/src/test/resources/META-INF/tika/presets.idx
new file mode 100644
index 0000000000..e98ae2649d
--- /dev/null
+++ b/tika-serialization/src/test/resources/META-INF/tika/presets.idx
@@ -0,0 +1,2 @@
+# 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..c28c46063d
--- /dev/null
+++ b/tika-serialization/src/test/resources/test-presets/builtin-sample.json
@@ -0,0 +1,3 @@
+{
+  "basic-content-handler-factory": {"type": "TEXT"}
+}
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..a8fe598bab 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,40 @@ 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);
+        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 c590a183d6..67ab84ad62 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
@@ -33,6 +33,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;
@@ -54,6 +55,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;
@@ -96,6 +98,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
@@ -115,6 +120,37 @@ 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: fail startup, not the first preset request
+            throw new IllegalStateException("Invalid 'presets' configuration", 
e);
+        }
+    }
+
+    /**
+     * A request context with the named preset's parse-context fragment merged 
in.
+     * A preset is selected whole and exclusively -- the {@code preset} routes 
take
+     * no config part, so it never combines with request-supplied 
configuration.
+     *
+     * @throws NotFoundException if no preset has this name
+     */
+    public ParseContext createPresetContext(String presetName) {
+        String fragment = presetRegistry.parseContextJson(presetName);
+        if (fragment == null) {
+            throw new NotFoundException("No such preset: " + presetName);
+        }
+        ParseContext context = createRequestContext();
+        try {
+            mergeParseContextFromConfig(fragment, context);
+        } catch (IOException | TikaConfigException e) {
+            // the preset came from Tika or the server config, so this is a
+            // server-side configuration error, not a caller error
+            throw new WebApplicationException(
+                    "Preset '" + presetName + "' failed to resolve: " + 
e.getMessage(), 500);
+        }
+        return context;
     }
 
     /**
@@ -580,6 +616,113 @@ public class TikaResource {
         return putJson(is, httpHeaders, handlerTypeName);
     }
 
+    // ==================== PUT preset endpoints ====================
+
+    // Mirrors of the PUT endpoints above with a vetted, named parse-context 
fragment
+    // applied: 
/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/* -- or a single preset -- independently of 
/tika/config*.
+    // These routes take no config part; a preset never combines with request 
config.
+
+    private Response putRawPreset(InputStream is, HttpHeaders httpHeaders, 
String presetName,
+                                  String handlerTypeName) throws IOException {
+        ParseContext context = createPresetContext(presetName);
+        Metadata metadata = newRequestMetadata();
+        fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            return produceRawOutputWithContext(tis, metadata, context, 
handlerTypeName);
+        }
+    }
+
+    private Metadata putJsonPreset(InputStream is, HttpHeaders httpHeaders, 
String presetName,
+                                   String handlerTypeName) throws IOException {
+        ParseContext context = createPresetContext(presetName);
+        Metadata metadata = newRequestMetadata();
+        fillMetadata(null, metadata, httpHeaders.getRequestHeaders());
+        try (TikaInputStream tis = TikaInputStream.get(is)) {
+            return produceJsonWithContext(tis, metadata, context, 
handlerTypeName);
+        }
+    }
+
+    /** As the bare /tika endpoint (Markdown), with the named preset applied. 
*/
+    @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, "md");
+    }
+
+    /** 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..148a86e239 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
@@ -224,6 +224,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/PresetEndpointsTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
new file mode 100644
index 0000000000..0325dc910c
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PresetEndpointsTest.java
@@ -0,0 +1,142 @@
+/*
+ * 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 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");
+        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 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..2f296defe9
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/resource/TikaResourcePresetTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.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.BasicContentHandlerFactory;
+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 testPresetExpandsIntoRequestContext() throws Exception {
+        ParseContext context =
+                newTikaResource(CONFIG, 
true).createPresetContext("xml-content");
+        BasicContentHandlerFactory chf =
+                (BasicContentHandlerFactory) 
context.get(ContentHandlerFactory.class);
+        assertEquals(BasicContentHandlerFactory.HANDLER_TYPE.XML, 
chf.getType());
+    }
+
+    @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");
+        BasicContentHandlerFactory chf =
+                (BasicContentHandlerFactory) 
context.get(ContentHandlerFactory.class);
+        assertEquals(BasicContentHandlerFactory.HANDLER_TYPE.XML, 
chf.getType());
+    }
+
+    @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));
+    }
+}

Reply via email to