TikaObjectMapperFactory.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.util.List;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.parser.pages.PagesConfig;
import org.apache.tika.renderer.RenderSettings;
import org.apache.tika.serialization.ComponentNameResolver;
import org.apache.tika.serialization.OperatorOnlyGuard;
import org.apache.tika.serialization.TikaModule;

/**
 * Factory for creating ObjectMappers configured for Tika serialization.
 * <p>
 * Configures strict validation settings and loads component registries
 * for friendly name resolution.
 */
public class TikaObjectMapperFactory {

    private static final Logger LOG = LoggerFactory.getLogger(TikaObjectMapperFactory.class);

    /**
     * Index file names for component registries.
     */
    private static final String[] REGISTRY_INDEX_FILES = {
            "parsers",
            "detectors",
            "encoding-detectors",
            "metadata-filters",
            "image-checks",
            "renderers",
            "translators",
            "digester-factories",
            "content-handler-factories",
            "parse-context"
    };

    private static ObjectMapper MAPPER = null;

    // Shared plain ObjectMapper (no TikaModule) for converting JsonNodes to JSON strings.
    // Needed because the main mapper may use a binary format (e.g., Smile)
    // which doesn't support writeValueAsString().
    private static final ObjectMapper PLAIN_MAPPER = new ObjectMapper();

    static {
        // Components with no bean properties (e.g., parsers with no configuration)
        // need to serialize as empty objects rather than throwing.
        PLAIN_MAPPER.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
        sparseOverlays(PLAIN_MAPPER);
    }

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private abstract static class Sparse {
    }

    /** An overlay config is mostly null: a dump writes what is set. Those classes only. */
    private static void sparseOverlays(ObjectMapper mapper) {
        for (Class<?> overlay : List.of(PagesConfig.class, PagesConfig.Ocr.class,
                PagesConfig.Auto.class, PagesConfig.Emit.class, RenderSettings.class)) {
            mapper.addMixIn(overlay, Sparse.class);
        }
    }

    /**
     * Returns a shared plain ObjectMapper without TikaModule registration.
     * <p>
     * This mapper is suitable for:
     * <ul>
     *   <li>Converting JsonNodes to JSON strings</li>
     *   <li>Serializing component properties without compact format wrapping</li>
     *   <li>Avoiding infinite recursion when serializing inside TikaModule</li>
     * </ul>
     * <p>
     * Has {@code FAIL_ON_EMPTY_BEANS} disabled to allow serialization of classes with no properties.
     *
     * @return the shared plain ObjectMapper
     */
    public static ObjectMapper getPlainMapper() {
        return PLAIN_MAPPER;
    }

    /**
     * Reads a component's JSON config; called by tika-core's ConfigDeserializer by reflection.
     * Untrusted JSON may not set an {@link org.apache.tika.config.OperatorOnly} property.
     */
    public static <T> T readConfig(String json, Class<T> configClass, boolean trusted)
            throws Exception {
        ObjectMapper mapper = getMapper();
        if (!trusted) {
            OperatorOnlyGuard.check(mapper, nestedName(configClass), json, configClass);
        }
        return ComponentInstantiator.withTrust(mapper.readerFor(configClass), trusted).readValue(json);
    }

    /** {@code PDFParser$Config}, not {@code Config}: names the component that owns the config. */
    private static String nestedName(Class<?> configClass) {
        return configClass.getName().substring(configClass.getPackageName().length() + 1);
    }

    public static synchronized ObjectMapper getMapper() {
        if (MAPPER == null) {
            MAPPER = createMapper();
        }
        return MAPPER;
    }

    /**
     * Creates an ObjectMapper configured for Tika serialization.
     *
     * @return configured ObjectMapper
     */
    public static ObjectMapper createMapper() {
        return createMapper(null);
    }

    /**
     * Creates an ObjectMapper configured for Tika serialization with a custom JsonFactory.
     * <p>
     * This can be used to create mappers for binary formats like Smile:
     * <pre>
     * ObjectMapper smileMapper = TikaObjectMapperFactory.createMapper(new SmileFactory());
     * </pre>
     *
     * @param factory the JsonFactory to use, or null for default JSON
     * @return configured ObjectMapper
     */
    public static ObjectMapper createMapper(JsonFactory factory) {
        ObjectMapper mapper = strict((factory != null) ? new ObjectMapper(factory) : new ObjectMapper());
        // Prevent null values being assigned to primitive fields (int, boolean, etc.)
        mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true);

        sparseOverlays(mapper);

        // Load component registries for name resolution
        loadComponentRegistries();

        // Register TikaModule for compact serialization/deserialization of registered components
        TikaModule tikaModule = new TikaModule();
        mapper.registerModule(tikaModule);

        // Set the shared mapper for TikaModule's deserializers
        TikaModule.setSharedMapper(mapper);

        return mapper;
    }

    /**
     * A strict mapper without Tika's component serializers, for plugin configuration
     * ({@code PluginJson}). Same checks as {@link #getMapper()} except
     * {@code FAIL_ON_NULL_FOR_PRIMITIVES}, which also rejects an absent primitive record
     * component, and optional primitives with defaults are the norm in plugin configs.
     */
    public static ObjectMapper createStrictPlainMapper() {
        return strict(new ObjectMapper());
    }

    private static ObjectMapper strict(ObjectMapper mapper) {
        // Allow comments in JSON config files (// and /* */ style)
        mapper.configure(JsonParser.Feature.ALLOW_COMMENTS, true);
        // Duplicate keys: the parser feature covers readValue; the tree feature only readTree
        mapper.configure(JsonParser.Feature.STRICT_DUPLICATE_DETECTION, true);
        mapper.configure(DeserializationFeature.FAIL_ON_READING_DUP_TREE_KEY, true);
        // Fail on unknown properties to catch configuration errors early
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true);
        // Ensure enums are properly validated (not just numeric values)
        mapper.configure(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS, true);
        // Accept enum values in any case ("no_ocr" as well as "NO_OCR"): hand-written
        // requests and older examples spell them either way
        mapper.configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true);
        // Need to allow creation of classes without setters/getters -- we may want to revisit this
        mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);
        return mapper;
    }

    /**
     * Loads component registries for name resolution.
     * Registries are loaded from index files and registered with the ComponentNameResolver.
     * Missing registries are silently ignored (may not be on classpath).
     */
    private static void loadComponentRegistries() {
        ClassLoader classLoader = TikaObjectMapperFactory.class.getClassLoader();

        for (String indexFile : REGISTRY_INDEX_FILES) {
            try {
                ComponentRegistry registry = new ComponentRegistry(indexFile, classLoader);
                ComponentNameResolver.registerRegistry(indexFile, registry);
                LOG.debug("Loaded component registry: {}", indexFile);
            } catch (TikaConfigException e) {
                // Registry not available - this is expected if the module isn't on classpath
                LOG.debug("Component registry not available: {} - {}", indexFile, e.getMessage());
            }
        }
    }
}