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)); + } +}
