PresetRegistry.java
/*
* 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);
}
}