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

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 68e5762116 TIKA-4889: engines config section and 
openai-embedding-engine (#3154)
68e5762116 is described below

commit 68e57621168adf9e8e3004e3ff4ea6fd5e4c3cd7
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 10 20:05:22 2026 -0400

    TIKA-4889: engines config section and openai-embedding-engine (#3154)
---
 CHANGES.txt                                        |   6 +
 .../org/apache/tika/parser/inference/Engine.java   |  27 +++
 .../tika/parser/inference/EngineRegistry.java      |  39 ++++
 .../org/apache/tika/inference/EmbeddingEngine.java |  34 ++++
 .../tika/inference/OpenAIEmbeddingEngine.java      | 210 +++++++++++++++++++++
 .../tika/inference/OpenAIEmbeddingEngineTest.java  | 113 +++++++++++
 .../apache/tika/config/loader/EngineLoader.java    |  77 ++++++++
 .../apache/tika/config/loader/TikaJsonConfig.java  |   1 +
 .../org/apache/tika/config/loader/TikaLoader.java  |   4 +
 .../tika/config/loader/EngineLoaderTest.java       |  78 ++++++++
 .../org/apache/tika/config/loader/TestEngine.java  |  35 ++++
 11 files changed, 624 insertions(+)

diff --git a/CHANGES.txt b/CHANGES.txt
index 7d5a977ed0..9fded46d6c 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,11 @@
 Release 4.1.0 - unreleased
 
+   * New "engines" config section: a map of names to inference engines, each
+     an endpoint or local binary with its settings (openai-embedding-engine
+     posts up to maxBatchSize images per request to an OpenAI-compatible
+     /v1/embeddings). Engines are called by the inference bindings that
+     follow; nothing calls them yet (TIKA-4889).
+
    * The image embedder writes a picture's vector onto the document the
      picture appears in (INLINE and RENDERING children) with an "embedded"
      locator naming the picture; attachments keep their own, and
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/Engine.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/Engine.java
new file mode 100644
index 0000000000..ba71a2d0b9
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/Engine.java
@@ -0,0 +1,27 @@
+/*
+ * 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.parser.inference;
+
+/**
+ * A model or service an inference binding calls: one endpoint or one local 
binary with its
+ * settings, named in the {@code "engines"} map. An engine knows nothing about 
documents; a
+ * binding says what it is fed and what to ask.
+ *
+ * @since Apache Tika 4.1
+ */
+public interface Engine {
+}
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/EngineRegistry.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/EngineRegistry.java
new file mode 100644
index 0000000000..39a57d3cc7
--- /dev/null
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/EngineRegistry.java
@@ -0,0 +1,39 @@
+/*
+ * 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.parser.inference;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** The {@code "engines"} map: user-chosen name to engine, built once at 
config load. */
+public final class EngineRegistry {
+
+    private final Map<String, Engine> engines;
+
+    public EngineRegistry(Map<String, Engine> engines) {
+        this.engines = Collections.unmodifiableMap(new 
LinkedHashMap<>(engines));
+    }
+
+    public Engine get(String name) {
+        return engines.get(name);
+    }
+
+    public Map<String, Engine> getEngines() {
+        return engines;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
new file mode 100644
index 0000000000..169b233967
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
@@ -0,0 +1,34 @@
+/*
+ * 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.inference;
+
+import java.io.IOException;
+import java.util.List;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.parser.inference.Engine;
+
+/** An engine that turns images into vectors, many per request. */
+public interface EmbeddingEngine extends Engine {
+
+    /** One request; the vectors come back in the order of the images. */
+    List<float[]> embedImages(List<byte[]> images, List<String> mimeTypes, 
ParseContext context)
+            throws IOException, TikaException;
+
+    int getMaxBatchSize();
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
new file mode 100644
index 0000000000..90e85f5d2d
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
@@ -0,0 +1,210 @@
+/*
+ * 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.inference;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.Initializable;
+import org.apache.tika.config.ParseTimeout;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.http.TikaHttpClient;
+import org.apache.tika.parser.ParseContext;
+import org.apache.tika.utils.StringUtils;
+
+/**
+ * An OpenAI-compatible embeddings endpoint ({@code POST /v1/embeddings}), 
named in
+ * {@code "engines"}. Sends up to {@code maxBatchSize} images per request as
+ * {@code {"image": "data:<mime>;base64,..."}} inputs and reads the vectors 
back by index.
+ */
+@TikaComponent(name = "openai-embedding-engine", spi = false)
+public class OpenAIEmbeddingEngine implements EmbeddingEngine, Initializable, 
Closeable {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private String baseUrl = "http://localhost:8000";;
+    private String model = "";
+    private String apiKey = "";
+    private long timeoutMillis = 120_000;
+    private int maxBatchSize = 32;
+    private String embeddingsPath = "/v1/embeddings";
+    private String apiKeyHeaderName = "Authorization";
+    private String apiKeyPrefix = "Bearer ";
+
+    private TikaHttpClient httpClient;
+
+    @Override
+    public void initialize() throws TikaConfigException {
+        if (StringUtils.isBlank(baseUrl)) {
+            throw new TikaConfigException("openai-embedding-engine needs a 
baseUrl");
+        }
+        if (maxBatchSize < 1) {
+            throw new TikaConfigException("maxBatchSize must be at least 1");
+        }
+        httpClient = TikaHttpClient.build(30);
+    }
+
+    @Override
+    public List<float[]> embedImages(List<byte[]> images, List<String> 
mimeTypes,
+                                     ParseContext context) throws IOException, 
TikaException {
+        if (httpClient == null) {
+            httpClient = TikaHttpClient.build(30);
+        }
+        ObjectNode root = MAPPER.createObjectNode();
+        if (!StringUtils.isBlank(model)) {
+            root.put("model", model);
+        }
+        ArrayNode input = root.putArray("input");
+        for (int i = 0; i < images.size(); i++) {
+            input.addObject().put("image", "data:" + mimeTypes.get(i) + 
";base64,"
+                    + Base64.getEncoder().encodeToString(images.get(i)));
+        }
+        Map<String, String> headers = new HashMap<>();
+        if (!StringUtils.isBlank(apiKey)) {
+            headers.put(apiKeyHeaderName, apiKeyPrefix + apiKey);
+        }
+        String url = baseUrl.replaceAll("/+$", "") + embeddingsPath;
+        String body = httpClient.postJson(url, root.toString(), headers, 
timeoutMillis, context);
+        ParseTimeout.checkpoint(context);
+        return parseResponse(body, images.size());
+    }
+
+    static List<float[]> parseResponse(String body, int expected) throws 
TikaException {
+        try {
+            JsonNode data = MAPPER.readTree(body).get("data");
+            if (data == null || !data.isArray() || data.size() != expected) {
+                throw new TikaException("Embedding response has " + (data == 
null ? "no"
+                        : data.size()) + " data entries; expected " + 
expected);
+            }
+            List<float[]> vectors = new ArrayList<>(expected);
+            for (int i = 0; i < expected; i++) {
+                vectors.add(null);
+            }
+            for (int i = 0; i < data.size(); i++) {
+                JsonNode item = data.get(i);
+                int index = item.path("index").asInt(i);
+                JsonNode embedding = item.get("embedding");
+                if (index < 0 || index >= expected || embedding == null || 
!embedding.isArray()) {
+                    throw new TikaException("Embedding response entry " + i + 
" is malformed");
+                }
+                float[] vector = new float[embedding.size()];
+                for (int d = 0; d < vector.length; d++) {
+                    vector[d] = (float) embedding.get(d).asDouble();
+                    if (Float.isNaN(vector[d]) || Float.isInfinite(vector[d])) 
{
+                        throw new TikaException("Embedding " + index + " has 
an invalid float");
+                    }
+                }
+                if (vectors.get(index) != null) {
+                    throw new TikaException("Embedding response repeats index 
" + index);
+                }
+                vectors.set(index, vector);
+            }
+            for (int i = 0; i < expected; i++) {
+                if (vectors.get(i) == null) {
+                    throw new TikaException("Embedding response has no entry 
for index " + i);
+                }
+            }
+            return vectors;
+        } catch (IOException e) {
+            throw new TikaException("Failed to parse embedding response", e);
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (httpClient != null) {
+            httpClient.close();
+        }
+    }
+
+    public String getBaseUrl() {
+        return baseUrl;
+    }
+
+    public void setBaseUrl(String baseUrl) {
+        this.baseUrl = baseUrl;
+    }
+
+    public String getModel() {
+        return model;
+    }
+
+    public void setModel(String model) {
+        this.model = model;
+    }
+
+    public String getApiKey() {
+        return apiKey;
+    }
+
+    public void setApiKey(String apiKey) {
+        this.apiKey = apiKey;
+    }
+
+    public long getTimeoutMillis() {
+        return timeoutMillis;
+    }
+
+    public void setTimeoutMillis(long timeoutMillis) {
+        this.timeoutMillis = timeoutMillis;
+    }
+
+    @Override
+    public int getMaxBatchSize() {
+        return maxBatchSize;
+    }
+
+    public void setMaxBatchSize(int maxBatchSize) {
+        this.maxBatchSize = maxBatchSize;
+    }
+
+    public String getEmbeddingsPath() {
+        return embeddingsPath;
+    }
+
+    public void setEmbeddingsPath(String embeddingsPath) {
+        this.embeddingsPath = embeddingsPath;
+    }
+
+    public String getApiKeyHeaderName() {
+        return apiKeyHeaderName;
+    }
+
+    public void setApiKeyHeaderName(String apiKeyHeaderName) {
+        this.apiKeyHeaderName = apiKeyHeaderName;
+    }
+
+    public String getApiKeyPrefix() {
+        return apiKeyPrefix;
+    }
+
+    public void setApiKeyPrefix(String apiKeyPrefix) {
+        this.apiKeyPrefix = apiKeyPrefix;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
new file mode 100644
index 0000000000..04ed8d387c
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.exception.TikaException;
+import org.apache.tika.http.TikaTestHttpServer;
+import org.apache.tika.parser.ParseContext;
+
+public class OpenAIEmbeddingEngineTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+    private TikaTestHttpServer server;
+    private OpenAIEmbeddingEngine engine;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        server = new TikaTestHttpServer();
+        engine = new OpenAIEmbeddingEngine();
+        engine.setBaseUrl(server.url());
+        engine.setModel("clip");
+        engine.setApiKey("secret");
+        engine.initialize();
+    }
+
+    @AfterEach
+    void tearDown() throws Exception {
+        engine.close();
+        server.shutdown();
+    }
+
+    private static byte[] bytes(String s) {
+        return s.getBytes(StandardCharsets.UTF_8);
+    }
+
+    @Test
+    public void testManyImagesOneRequestPlacedByIndex() throws Exception {
+        // out of order on purpose: vectors are placed by index, not position
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, "{\"data\":["
+                + 
"{\"index\":1,\"embedding\":[1.0,0.5]},{\"index\":0,\"embedding\":[0.0,0.5]}]}"));
+        List<float[]> vectors = engine.embedImages(List.of(bytes("a"), 
bytes("b")),
+                List.of("image/png", "image/jpeg"), new ParseContext());
+        assertEquals(2, vectors.size());
+        assertEquals(0.0f, vectors.get(0)[0]);
+        assertEquals(1.0f, vectors.get(1)[0]);
+
+        TikaTestHttpServer.RecordedRequest request = server.takeRequest();
+        assertEquals("/v1/embeddings", request.path());
+        assertEquals("Bearer secret", request.header("Authorization"));
+        JsonNode body = MAPPER.readTree(request.body());
+        assertEquals("clip", body.get("model").asText());
+        assertEquals(2, body.get("input").size());
+        assertEquals("data:image/png;base64,YQ==", 
body.get("input").get(0).get("image").asText());
+        assertEquals("data:image/jpeg;base64,Yg==", 
body.get("input").get(1).get("image").asText());
+    }
+
+    @Test
+    public void testMalformedResponsesAreRefused() throws Exception {
+        String[] bad = {
+            "{\"data\":[{\"index\":0,\"embedding\":[1.0]}]}",
+            
"{\"data\":[{\"index\":0,\"embedding\":[1.0]},{\"index\":0,\"embedding\":[2.0]}]}",
+            
"{\"data\":[{\"index\":0,\"embedding\":[1.0]},{\"index\":5,\"embedding\":[2.0]}]}",
+            
"{\"data\":[{\"index\":0,\"embedding\":[1.0]},{\"index\":1,\"embedding\":\"x\"}]}",
+            
"{\"data\":[{\"index\":0,\"embedding\":[1.0]},{\"index\":1,\"embedding\":[\"NaN\"]}]}",
+            "{}",
+        };
+        for (String body : bad) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, body));
+            assertThrows(TikaException.class, () -> engine.embedImages(
+                    List.of(bytes("a"), bytes("b")), List.of("image/png", 
"image/png"),
+                    new ParseContext()), body);
+        }
+    }
+
+    @Test
+    public void testHttpErrorIsAnException() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(500, "boom"));
+        assertThrows(TikaException.class, () -> 
engine.embedImages(List.of(bytes("a")),
+                List.of("image/png"), new ParseContext()));
+    }
+
+    @Test
+    public void testInitializeValidates() {
+        OpenAIEmbeddingEngine bad = new OpenAIEmbeddingEngine();
+        bad.setMaxBatchSize(0);
+        assertThrows(org.apache.tika.exception.TikaConfigException.class, 
bad::initialize);
+    }
+}
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
new file mode 100644
index 0000000000..cc01e342f5
--- /dev/null
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EngineLoader.java
@@ -0,0 +1,77 @@
+/*
+ * 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.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.parser.inference.EngineRegistry;
+
+/**
+ * Loads {@code "engines"}: a map of user-chosen name to one engine,
+ * {@code {"clip": {"openai-embedding-engine": {...}}}}. Null when the key is 
absent.
+ */
+class EngineLoader implements ComponentLoader<EngineRegistry> {
+
+    static final String KEY = "engines";
+    private static final Pattern LEGAL_NAME = 
Pattern.compile("[A-Za-z0-9._-]+");
+
+    @Override
+    public EngineRegistry load(TikaJsonConfig config, LoaderContext context)
+            throws TikaConfigException {
+        JsonNode node = config.getRootNode().get(KEY);
+        if (node == null) {
+            return null;
+        }
+        if (!node.isObject()) {
+            throw new TikaConfigException("\"" + KEY + "\" must be an object 
keyed by engine "
+                    + "name, e.g. {\"clip\": {\"openai-embedding-engine\": 
{...}}}");
+        }
+        Map<String, Engine> engines = new LinkedHashMap<>();
+        Iterator<Map.Entry<String, JsonNode>> fields = node.fields();
+        while (fields.hasNext()) {
+            Map.Entry<String, JsonNode> e = fields.next();
+            String name = e.getKey();
+            if (!LEGAL_NAME.matcher(name).matches()) {
+                throw new TikaConfigException("engine name \"" + name + "\" 
may use only "
+                        + "letters, digits, '.', '_' and '-'");
+            }
+            JsonNode value = e.getValue();
+            if (!value.isObject() || value.size() != 1) {
+                throw new TikaConfigException("engine \"" + name + "\" must be 
one "
+                        + "{\"<engine-type>\": {...}} object");
+            }
+            Map.Entry<String, JsonNode> type = value.fields().next();
+            Engine engine;
+            try {
+                engine = 
ComponentInstantiator.instantiateComponent(type.getKey(),
+                        type.getValue(), context.getObjectMapper(), 
context.getClassLoader(),
+                        Engine.class);
+            } catch (TikaConfigException ex) {
+                throw new TikaConfigException("engine \"" + name + "\": " + 
ex.getMessage(), ex);
+            }
+            engines.put(name, engine);
+        }
+        return new EngineRegistry(engines);
+    }
+}
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 a3aecf240c..04fcf89073 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
@@ -114,6 +114,7 @@ public class TikaJsonConfig {
             "content-handler-factory",
             "renderers",
             "text-recognizers",
+            "engines",
             "translator",
             "auto-detect-parser",
             "parse-context",
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
index adb09a86db..594f93f815 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/TikaLoader.java
@@ -53,6 +53,7 @@ import org.apache.tika.parser.CompositeParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.enricher.CompositeContentEnricher;
+import org.apache.tika.parser.inference.EngineRegistry;
 import org.apache.tika.renderer.CompositeRenderer;
 import org.apache.tika.renderer.Renderer;
 import org.apache.tika.sax.BasicContentHandlerFactory;
@@ -138,6 +139,9 @@ public class TikaLoader {
         ComponentConfig.builder("text-recognizers", 
CompositeContentEnricher.class)
                 .customLoader(new ContentEnricherLoader())
                 .register();
+        ComponentConfig.builder(EngineLoader.KEY, EngineRegistry.class)
+                .customLoader(new EngineLoader())
+                .register();
 
         ComponentConfig.builder("translator", Translator.class)
                 .loadAsList()
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/EngineLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EngineLoaderTest.java
new file mode 100644
index 0000000000..034083f947
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EngineLoaderTest.java
@@ -0,0 +1,78 @@
+/*
+ * 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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.inference.EngineRegistry;
+
+public class EngineLoaderTest {
+
+    @TempDir
+    Path tmp;
+
+    private TikaLoader load(String json) throws Exception {
+        Path config = Files.createTempFile(tmp, "tika-config", ".json");
+        Files.writeString(config, json);
+        return TikaLoader.load(config);
+    }
+
+    @Test
+    public void testEnginesLoadByName() throws Exception {
+        TikaLoader loader = load("{ \"engines\": {"
+                + " \"one\": { \"test-engine\": { \"label\": \"first\" } },"
+                + " \"two\": { \"test-engine\": { \"label\": \"second\" } } } 
}");
+        EngineRegistry engines = loader.get(EngineRegistry.class);
+        assertEquals("first", ((TestEngine) engines.get("one")).getLabel());
+        assertEquals("second", ((TestEngine) engines.get("two")).getLabel());
+        assertNull(engines.get("three"));
+        assertEquals(2, engines.getEngines().size());
+    }
+
+    @Test
+    public void testAbsentSectionLoadsNothing() throws Exception {
+        assertNull(load("{ \"parsers\": [ { \"default-parser\": {} } ] }")
+                .get(EngineRegistry.class));
+    }
+
+    @Test
+    public void testMisconfigurationsFailLoad() throws Exception {
+        String[] bad = {
+            "{ \"engines\": [ { \"test-engine\": {} } ] }",
+            "{ \"engines\": { \"one\": { \"test-engine\": {}, 
\"test-engine-2\": {} } } }",
+            "{ \"engines\": { \"one\": {} } }",
+            "{ \"engines\": { \"bad name\": { \"test-engine\": {} } } }",
+            "{ \"engines\": { \"one\": { \"no-such-engine\": {} } } }",
+            "{ \"engines\": { \"one\": { \"test-engine\": { \"nope\": 1 } } } 
}",
+        };
+        for (String json : bad) {
+            TikaConfigException e = assertThrows(TikaConfigException.class,
+                    () -> load(json).get(EngineRegistry.class), json);
+            assertNotNull(e.getMessage());
+        }
+    }
+}
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/TestEngine.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestEngine.java
new file mode 100644
index 0000000000..d5559c7b00
--- /dev/null
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestEngine.java
@@ -0,0 +1,35 @@
+/*
+ * 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 org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.parser.inference.Engine;
+
+/** An engine that does nothing but carry a label, for loader tests. */
+@TikaComponent(name = "test-engine", spi = false)
+public class TestEngine implements Engine {
+
+    private String label = "";
+
+    public String getLabel() {
+        return label;
+    }
+
+    public void setLabel(String label) {
+        this.label = label;
+    }
+}

Reply via email to