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 75ebebc2b2 TIKA-4909: enable env variable interpolation in
tika-config.json (#3200)
75ebebc2b2 is described below
commit 75ebebc2b28127d0e80eed25fe40ecaa5fe8bc41
Author: Tim Allison <[email protected]>
AuthorDate: Fri Sep 18 16:02:04 2026 -0400
TIKA-4909: enable env variable interpolation in tika-config.json (#3200)
---
CHANGES.txt | 2 +
docs/modules/ROOT/pages/configuration/index.adoc | 29 ++++++
.../ROOT/pages/configuration/inference.adoc | 13 +--
.../pages/configuration/parsers/vlm-parsers.adoc | 3 +-
.../ROOT/pages/developers/custom-ocr-engine.adoc | 2 +-
.../inference-handler-requirements.adoc | 2 +-
.../ROOT/pages/pipes/plugins/elasticsearch.adoc | 3 +-
docs/modules/ROOT/pages/pipes/plugins/http.adoc | 1 +
.../ROOT/pages/pipes/plugins/opensearch.adoc | 3 +-
docs/modules/ROOT/pages/pipes/plugins/s3.adoc | 3 +-
docs/modules/ROOT/pages/using-tika/server/tls.adoc | 13 +--
.../apache/tika/config/loader/EnvInterpolator.java | 98 +++++++++++++++++++
.../apache/tika/config/loader/TikaJsonConfig.java | 6 +-
.../tika/config/loader/EnvInterpolatorTest.java | 108 +++++++++++++++++++++
.../core/PerRequestConfigNotInterpolatedTest.java | 98 +++++++++++++++++++
15 files changed, 365 insertions(+), 19 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index 26446a0a4a..8650c09085 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,7 @@
Release 4.1.0 - unreleased
+ * Enable environment variable interpolation in tika-config.json (TIKA-4909).
+
* Fix a cache-budget leak: tar, 7z and streamed zip entries never released
their in-memory cache's reservation (TIKA-4908).
diff --git a/docs/modules/ROOT/pages/configuration/index.adoc
b/docs/modules/ROOT/pages/configuration/index.adoc
index 541432897f..3d6aa1d0eb 100644
--- a/docs/modules/ROOT/pages/configuration/index.adoc
+++ b/docs/modules/ROOT/pages/configuration/index.adoc
@@ -256,6 +256,35 @@ engine still advertising them is treated as a text
recognizer for the real types
startup, until 5.0. Nothing else in Tika produces them; a
`tk:content-type-parser-override`
naming one matches no parser and falls to the empty fallback.
+[#env]
+== Secrets from the environment
+
+A string value in the startup config may name an environment variable as
`${env:NAME}`, alone or
+inside a longer string, and the loader substitutes the variable's value when
the config is read:
+
+[source,json]
+----
+{
+ "engines": {
+ "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai",
+ "apiKey": "${env:JINA_API_KEY}", "model":
"jina-embeddings-v5-omni-small" } }
+ },
+ "emitters": {
+ "es": { "es-emitter": { "esUrl": "http://${env:ES_HOST}:9200/tika",
"apiKey": "${env:ES_API_KEY}" } }
+ }
+}
+----
+
+Only that exact form is touched: `${NAME}`, `$NAME`, `${sys:...}` and any
other text stay literal,
+so a prompt or a pattern that happens to contain `${` is safe. A reference to
a variable that is not
+set fails the load with the variable's name and the JSON path, never a value,
so a missing secret
+stops the server at startup rather than reaching a service as an empty key. A
literal value works as
+before.
+
+The forked parse workers of tika-server and Pipes inherit the parent's
environment and read the same
+config file, so a reference resolves there too. Per-request configuration
(`/config` endpoints,
+presets, pipes tuples) is never interpolated: a request cannot read the
server's environment.
+
== Windows file paths
JSON treats the backslash as an escape character, so path options
(`tesseractPath`,
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc
b/docs/modules/ROOT/pages/configuration/inference.adoc
index d31a72d2f2..f3c3e66a3b 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -75,7 +75,7 @@ rendering as a PNG, never the PDF itself, and the PDF parser
renders only when i
{
"engines": {
"jina": { "openai-embedding-engine": {
- "baseUrl": "https://api.jina.ai", "apiKey": "...",
+ "baseUrl": "https://api.jina.ai", "apiKey": "${env:JINA_API_KEY}",
"model": "jina-embeddings-v5-omni-small", "maxBatchSize": 16,
"requestParameters": { "task": "retrieval.passage", "dimensions": 1024 }
} }
},
@@ -104,7 +104,7 @@ same engine with the defaults and `"baseUrl":
"https://api.openai.com"`:
"azure": { "openai-embedding-engine": {
"baseUrl": "https://my-resource.openai.azure.com",
"embeddingsPath":
"/openai/deployments/text-embedding-3-large/embeddings?api-version=2024-10-21",
- "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey": "...",
+ "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey":
"${env:AZURE_OPENAI_KEY}",
"requestParameters": { "dimensions": 1024 } } }
}
}
@@ -128,10 +128,10 @@ asynchronous, object-store-backed jobs that an embeddings
call cannot use.
{
"engines": {
"gemini": { "openai-embedding-engine": {
- "baseUrl": "http://litellm:4000", "apiKey": "...",
+ "baseUrl": "http://litellm:4000", "apiKey": "${env:LITELLM_KEY}",
"model": "gemini-embedding-2-preview", "imageInput": "data-uri",
"maxBatchSize": 6 } },
"nova": { "openai-embedding-engine": {
- "baseUrl": "http://litellm:4000", "apiKey": "...",
+ "baseUrl": "http://litellm:4000", "apiKey": "${env:LITELLM_KEY}",
"model": "bedrock/amazon.nova-2-multimodal-embeddings-v1:0",
"imageInput": "data-uri", "maxBatchSize": 1,
"requestParameters": { "dimensions": 1024, "embedding_purpose":
"GENERIC_INDEX" } } }
@@ -323,7 +323,7 @@ as mono Opus). Two bindings on one engine index both
channels of a video:
----
{
"engines": {
- "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai",
"apiKey": "...",
+ "jina": { "openai-embedding-engine": { "baseUrl": "https://api.jina.ai",
"apiKey": "${env:JINA_API_KEY}",
"model": "jina-embeddings-v5-omni-small", "mediaInput": "object",
"requestParameters": { "task": "retrieval.passage",
"dimensions": 1024 } } }
},
@@ -401,7 +401,8 @@ There is no Jina engine class; it is the OpenAI shape with
Jina's keys:
|`https://api.jina.ai` (the engine appends `/v1/embeddings`).
|`apiKey`
-|The Jina key; sent as `Authorization: Bearer`.
+|The Jina key, sent as `Authorization: Bearer`; `${env:JINA_API_KEY}` keeps it
out of the file
+(see xref:configuration/index.adoc#env[Secrets from the environment]).
|`model`
|`jina-embeddings-v5-omni-small` or `jina-embeddings-v5-omni-nano`.
diff --git a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
index 567e636207..b9c394a510 100644
--- a/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
+++ b/docs/modules/ROOT/pages/configuration/parsers/vlm-parsers.adoc
@@ -198,7 +198,8 @@ All three parsers share the same configuration POJO
(`VLMOCRConfig`):
|`apiKey`
|`""` (empty)
|API key. Format depends on the parser (Bearer header, x-api-key header,
-or query parameter).
+or query parameter). `${env:NAME}` reads it from the environment
+(xref:configuration/index.adoc#env[Secrets from the environment]).
|`inlineContent`
|`true`
diff --git a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
index 340456b036..a44673d0a4 100644
--- a/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
+++ b/docs/modules/ROOT/pages/developers/custom-ocr-engine.adoc
@@ -210,7 +210,7 @@ xref:configuration/index.adoc#tika-extras-dir[Adding extra
jars].
----
{
"engines": {
- "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000", "apiKey":
"..." } }
+ "jina": { "jina-ocr-parser": { "endpoint": "http://jina:9000", "apiKey":
"${env:JINA_API_KEY}" } }
},
"text-recognizers": [
{ "engine": "jina" }
diff --git
a/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
b/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
index 6bd4ccc521..8a52e837b4 100644
---
a/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
+++
b/docs/modules/ROOT/pages/migration-to-4x/inference-handler-requirements.adoc
@@ -101,7 +101,7 @@ A full pipeline, with the handler type stated explicitly
and chunks emitted to E
"openai-embedding-filter": {
"baseUrl": "https://api.openai.com",
"model": "text-embedding-3-small",
- "apiKey": "${OPENAI_API_KEY}",
+ "apiKey": "${env:OPENAI_API_KEY}",
"maxChunkChars": 1500,
"overlapChars": 200
}
diff --git a/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
b/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
index 1b63064fb8..904bbfd244 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/elasticsearch.adoc
@@ -143,7 +143,8 @@ include::example$pipes-elasticsearch-emitter.json[]
|`apiKey`
|_none_
-|Base64-encoded `id:api_key`. See <<credentials>>.
+|Base64-encoded `id:api_key`. See <<credentials>>. `${env:ES_API_KEY}` reads
it from the
+environment (xref:configuration/index.adoc#env[Secrets from the environment]).
|`httpClientConfig`
|_none_
diff --git a/docs/modules/ROOT/pages/pipes/plugins/http.adoc
b/docs/modules/ROOT/pages/pipes/plugins/http.adoc
index 9ce1dd34a5..fea9d0e2a7 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/http.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/http.adoc
@@ -49,6 +49,7 @@ include::example$pipes-http-fetcher.json[]
|`userName` / `password`
|_none_
|Credentials. Applied only when `authScheme` is also set — without it they are
silently ignored.
+`${env:NAME}` reads one from the environment
(xref:configuration/index.adoc#env[Secrets from the environment]).
|`ntDomain`
|_none_
diff --git a/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
b/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
index a57f45b48a..fd762c480f 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/opensearch.adoc
@@ -51,7 +51,8 @@ client by either component, so setting them has no effect:
|`userName` / `password`
|_none_
-|Basic-auth credentials. Omit both for an anonymous client.
+|Basic-auth credentials. Omit both for an anonymous client.
`${env:OPENSEARCH_PASSWORD}` reads
+the password from the environment (xref:configuration/index.adoc#env[Secrets
from the environment]).
|`verifySsl`
|`true`
diff --git a/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
b/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
index 468844ea00..2721686204 100644
--- a/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
+++ b/docs/modules/ROOT/pages/pipes/plugins/s3.adoc
@@ -83,7 +83,8 @@ include::example$pipes-s3-fetcher.json[]
|`profile` / `accessKey` / `secretKey`
|_conditional_
-|Required by the matching `credentialsProvider`.
+|Required by the matching `credentialsProvider`.
`${env:AWS_SECRET_ACCESS_KEY}` keeps the
+secret out of the file (xref:configuration/index.adoc#env[Secrets from the
environment]).
|`spoolToTemp`
|`true`
diff --git a/docs/modules/ROOT/pages/using-tika/server/tls.adoc
b/docs/modules/ROOT/pages/using-tika/server/tls.adoc
index 56619f917b..0fbcf7e6af 100644
--- a/docs/modules/ROOT/pages/using-tika/server/tls.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/tls.adoc
@@ -56,7 +56,7 @@ Server authenticates to clients; clients present no
certificate.
"active": true,
"keyStoreType": "PKCS12",
"keyStoreFile": "/path/to/server-keystore.p12",
- "keyStorePassword": "your-password",
+ "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
"clientAuthenticationWanted": false,
"clientAuthenticationRequired": false
}
@@ -78,10 +78,10 @@ Add a truststore holding the client certificates you trust,
and require client a
"active": true,
"keyStoreType": "PKCS12",
"keyStoreFile": "/path/to/server-keystore.p12",
- "keyStorePassword": "your-password",
+ "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
"trustStoreType": "PKCS12",
"trustStoreFile": "/path/to/server-truststore.p12",
- "trustStorePassword": "your-password",
+ "trustStorePassword": "${env:TRUSTSTORE_PASSWORD}",
"clientAuthenticationWanted": true,
"clientAuthenticationRequired": true
}
@@ -115,7 +115,8 @@ All properties below go in `server.tlsConfig`.
|`keyStorePassword`
|string
|null
-|Password for the keystore.
+|Password for the keystore; `${env:KEYSTORE_PASSWORD}` reads it from the
environment
+(xref:configuration/index.adoc#env[Secrets from the environment]).
|`trustStoreType`
|string
@@ -304,10 +305,10 @@ With `/tika/config/tika-config.json` holding, for example:
"active": true,
"keyStoreType": "PKCS12",
"keyStoreFile": "/tika/certs/server-keystore.p12",
- "keyStorePassword": "your-password",
+ "keyStorePassword": "${env:KEYSTORE_PASSWORD}",
"trustStoreType": "PKCS12",
"trustStoreFile": "/tika/certs/server-truststore.p12",
- "trustStorePassword": "your-password",
+ "trustStorePassword": "${env:TRUSTSTORE_PASSWORD}",
"clientAuthenticationRequired": true
}
}
diff --git
a/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
new file mode 100644
index 0000000000..71cedb4870
--- /dev/null
+++
b/tika-serialization/src/main/java/org/apache/tika/config/loader/EnvInterpolator.java
@@ -0,0 +1,98 @@
+/*
+ * 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.Map;
+import java.util.function.Function;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+
+import org.apache.tika.exception.TikaConfigException;
+
+/**
+ * Resolves {@code ${env:NAME}} inside the string values of a startup config
to the
+ * environment variable's value, in place. Only that exact form is touched:
{@code ${NAME}},
+ * {@code $NAME} and everything else stay literal. A reference to an unset
variable fails the
+ * load, naming the variable and the JSON path but never a value. Applied by
+ * {@link TikaJsonConfig#load} only, never to JSON a request supplies.
+ *
+ * @since Apache Tika 4.1
+ */
+final class EnvInterpolator {
+
+ static final Pattern REFERENCE =
Pattern.compile("\\$\\{env:([A-Za-z_][A-Za-z0-9_]*)}");
+
+ private EnvInterpolator() {
+ }
+
+ static void resolve(JsonNode root, Function<String, String> env) throws
TikaConfigException {
+ resolve(root, env, "");
+ }
+
+ private static void resolve(JsonNode node, Function<String, String> env,
String path)
+ throws TikaConfigException {
+ if (node instanceof ObjectNode) {
+ ObjectNode object = (ObjectNode) node;
+ Iterator<Map.Entry<String, JsonNode>> fields = object.fields();
+ while (fields.hasNext()) {
+ Map.Entry<String, JsonNode> field = fields.next();
+ String childPath = path + "/" + field.getKey();
+ if (field.getValue().isTextual()) {
+ field.setValue(resolveText(field.getValue().textValue(),
env, childPath));
+ } else {
+ resolve(field.getValue(), env, childPath);
+ }
+ }
+ } else if (node instanceof ArrayNode) {
+ ArrayNode array = (ArrayNode) node;
+ for (int i = 0; i < array.size(); i++) {
+ String childPath = path + "/" + i;
+ if (array.get(i).isTextual()) {
+ array.set(i, resolveText(array.get(i).textValue(), env,
childPath));
+ } else {
+ resolve(array.get(i), env, childPath);
+ }
+ }
+ }
+ }
+
+ private static TextNode resolveText(String text, Function<String, String>
env, String path)
+ throws TikaConfigException {
+ Matcher m = REFERENCE.matcher(text);
+ if (!m.find()) {
+ return TextNode.valueOf(text);
+ }
+ StringBuilder sb = new StringBuilder();
+ do {
+ String name = m.group(1);
+ String value = env.apply(name);
+ if (value == null) {
+ throw new TikaConfigException("environment variable " + name
+ + " is not set; referenced at " + path);
+ }
+ m.appendReplacement(sb, Matcher.quoteReplacement(value));
+ } while (m.find());
+ m.appendTail(sb);
+ return TextNode.valueOf(sb.toString());
+ }
+}
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 eae5a0555c..7c363054a6 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
@@ -161,7 +161,9 @@ public class TikaJsonConfig {
}
/**
- * Loads configuration from an input stream.
+ * Loads configuration from an input stream. A string value may reference
an environment
+ * variable as {@code ${env:NAME}}, alone or inside a longer string; an
unset one fails
+ * the load. No other form is interpolated.
*
* @param inputStream the input stream containing JSON configuration
* @return the parsed configuration
@@ -170,6 +172,8 @@ public class TikaJsonConfig {
public static TikaJsonConfig load(InputStream inputStream) throws
TikaConfigException {
try {
JsonNode rootNode = OBJECT_MAPPER.readTree(inputStream);
+ // startup config only: a request's JSON never reaches this method
+ EnvInterpolator.resolve(rootNode, System::getenv);
TikaJsonConfig tikaJsonConfig = new TikaJsonConfig(rootNode);
tikaJsonConfig.validateKeys();
return tikaJsonConfig;
diff --git
a/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
new file mode 100644
index 0000000000..034486fca3
--- /dev/null
+++
b/tika-serialization/src/test/java/org/apache/tika/config/loader/EnvInterpolatorTest.java
@@ -0,0 +1,108 @@
+/*
+ * 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.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.exception.TikaConfigException;
+
+public class EnvInterpolatorTest {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private static JsonNode resolve(String json, Map<String, String> env)
throws Exception {
+ JsonNode root = MAPPER.readTree(json);
+ EnvInterpolator.resolve(root, env::get);
+ return root;
+ }
+
+ @Test
+ public void testWholeValueAndEmbedded() throws Exception {
+ JsonNode root = resolve("""
+ { "engines": { "jina": { "openai-embedding-engine": {
+ "apiKey": "${env:JINA_API_KEY}",
+ "baseUrl": "https://${env:JINA_HOST}/v1",
+ "requestParameters": { "task": "retrieval.passage" } } } },
+ "parsers": [ "html-parser", { "pdf-parser": { "ocr": {
"language": "${env:LANG_A}+${env:LANG_B}" } } } ] }
+ """, Map.of("JINA_API_KEY", "s3cret", "JINA_HOST",
"api.jina.ai",
+ "LANG_A", "eng", "LANG_B", "deu"));
+ JsonNode engine = root.at("/engines/jina/openai-embedding-engine");
+ assertEquals("s3cret", engine.get("apiKey").asText());
+ assertEquals("https://api.jina.ai/v1", engine.get("baseUrl").asText());
+ assertEquals("retrieval.passage",
engine.at("/requestParameters/task").asText(),
+ "untouched strings stay");
+ assertEquals("eng+deu",
root.at("/parsers/1/pdf-parser/ocr/language").asText(),
+ "resolved inside arrays too");
+ assertEquals("html-parser", root.at("/parsers/0").asText());
+ }
+
+ @Test
+ public void testOnlyTheEnvFormIsTouched() throws Exception {
+ JsonNode root = resolve("""
+ { "engines": { "vlm": { "openai-vlm-parser": {
+ "prompt": "Return ${fields} as $JSON and ${env:} and
${sys:HOME} and $${env:X}" } } } }
+ """, Map.of("X", "no", "fields", "no", "HOME", "no"));
+ assertEquals("Return ${fields} as $JSON and ${env:} and ${sys:HOME}
and $no",
+ root.at("/engines/vlm/openai-vlm-parser/prompt").asText());
+ }
+
+ @Test
+ public void testUnsetFailsNamingTheVariableAndPathNotAValue() throws
Exception {
+ TikaConfigException e = assertThrows(TikaConfigException.class, () ->
resolve("""
+ { "emitters": { "es": { "es-emitter": { "apiKey":
"${env:ES_KEY}",
+ "esUrl": "http://x" } } } }
+ """, Map.of("OTHER", "value")));
+ assertTrue(e.getMessage().contains("ES_KEY"), e.getMessage());
+ assertTrue(e.getMessage().contains("/emitters/es/es-emitter/apiKey"),
e.getMessage());
+ assertFalse(e.getMessage().contains("value"), e.getMessage());
+ }
+
+ @Test
+ public void testNonStringsUntouched() throws Exception {
+ JsonNode root = resolve("""
+ { "pipes": { "numClients": 4, "forkedJvmArgs":
["-Xmx${env:HEAP}"], "on": true, "n": null } }
+ """, Map.of("HEAP", "2g"));
+ assertEquals(4, root.at("/pipes/numClients").asInt());
+ assertEquals("-Xmx2g", root.at("/pipes/forkedJvmArgs/0").asText());
+ assertTrue(root.at("/pipes/on").asBoolean());
+ assertTrue(root.at("/pipes/n").isNull());
+ }
+
+ /** The real entry point resolves against the process environment. */
+ @Test
+ public void testLoadResolvesFromTheProcessEnvironment() throws Exception {
+ String var = System.getenv().containsKey("PATH") ? "PATH" : "HOME";
+ String json = "{ \"pipes\": { \"tempDirectory\": \"${env:" + var +
"}\" } }";
+ TikaJsonConfig config = TikaJsonConfig.load(
+ new
ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8)));
+ assertEquals(System.getenv(var),
config.getRootNode().at("/pipes/tempDirectory").asText());
+ assertThrows(TikaConfigException.class, () -> TikaJsonConfig.load(new
ByteArrayInputStream(
+ "{ \"pipes\": { \"tempDirectory\":
\"${env:TIKA_SURELY_UNSET_VARIABLE_4909}\" } }"
+ .getBytes(StandardCharsets.UTF_8))));
+ }
+}
diff --git
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
new file mode 100644
index 0000000000..1ab3456da6
--- /dev/null
+++
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/PerRequestConfigNotInterpolatedTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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 static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+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 jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.JAXRSServerFactoryBean;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.apache.cxf.jaxrs.ext.multipart.Attachment;
+import org.apache.cxf.jaxrs.ext.multipart.ContentDisposition;
+import org.apache.cxf.jaxrs.ext.multipart.MultipartBody;
+import org.apache.cxf.jaxrs.lifecycle.SingletonResourceProvider;
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.serialization.JsonMetadataList;
+import org.apache.tika.server.core.resource.RecursiveMetadataResource;
+import org.apache.tika.server.core.writer.MetadataListMessageBodyWriter;
+
+/**
+ * {@code ${env:NAME}} is resolved in the startup config only (TIKA-4909). A
request's config
+ * part must reach the parse as the literal string: a caller must not be able
to read the
+ * server's environment through it.
+ */
+public class PerRequestConfigNotInterpolatedTest extends CXFTestBase {
+
+ private static final String TEST_DOC =
"test-documents/mock/hello_world.xml";
+
+ @Override
+ protected boolean isAllowPerRequestConfig() {
+ return true;
+ }
+
+ @Override
+ protected void setUpResources(JAXRSServerFactoryBean sf) {
+ sf.setResourceClasses(RecursiveMetadataResource.class);
+ sf.setResourceProvider(RecursiveMetadataResource.class,
+ new SingletonResourceProvider(new
RecursiveMetadataResource(tikaResource)));
+ }
+
+ @Override
+ protected void setUpProviders(JAXRSServerFactoryBean sf) {
+ List<Object> providers = new ArrayList<>();
+ providers.add(new MetadataListMessageBodyWriter());
+ providers.add(new TikaServerParseExceptionMapper());
+ providers.add(new BadRequestExceptionMapper());
+ sf.setProviders(providers);
+ }
+
+ @Test
+ public void testRequestConfigKeepsEnvReferenceLiteral() throws Exception {
+ String var = System.getenv().containsKey("PATH") ? "PATH" : "HOME";
+ // renames Content-Type to whatever the value resolves to: the
literal, if not interpolated
+ String config = "{\"metadata-filters\": [ {
\"field-name-mapping-filter\": {"
+ + "\"mappings\": { \"Content-Type\": \"${env:" + var + "}\" },"
+ + "\"excludeUnmapped\": false } } ] }";
+ ContentDisposition cd =
+ new ContentDisposition("form-data; name=\"file\";
filename=\"hello_world.xml\"");
+ List<Attachment> atts = new ArrayList<>();
+ atts.add(new Attachment("file",
ClassLoader.getSystemResourceAsStream(TEST_DOC), cd));
+ atts.add(new Attachment("config", "application/json",
+ new ByteArrayInputStream(config.getBytes(UTF_8))));
+ Response response = WebClient.create(endPoint + "/rmeta/config")
+ .type("multipart/form-data").accept("application/json")
+ .post(new MultipartBody(atts));
+ assertEquals(200, response.getStatus());
+ Reader reader = new InputStreamReader((InputStream)
response.getEntity(), UTF_8);
+ Metadata m = JsonMetadataList.fromJson(reader).get(0);
+ assertNotNull(m.get("${env:" + var + "}"), "the literal name is the
mapped field");
+ assertNull(m.get(System.getenv(var)), "the environment value never
became a field name");
+ assertNull(m.get("Content-Type"), "the mapping did apply");
+ }
+}