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 82141b6248 TIKA-5025: Set the server block's fields from the command 
line and environment (TIKA_SERVER_SETTING_<FIELD>) (#3519)
82141b6248 is described below

commit 82141b62485e6b0db7f2eb3388d33ad14b6e9463
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Nov 5 15:28:38 2026 +0100

    TIKA-5025: Set the server block's fields from the command line and 
environment (TIKA_SERVER_SETTING_<FIELD>) (#3519)
    
    * TIKA-5025: Set the server block's fields from the command line and 
environment
    
    tika-server takes --serverSetting <field>=<value> (repeatable) and
    TIKA_SERVER_<FIELD> (TIKA_SERVER_MAX_REQUEST_SIZE_BYTES), bound through
    the same Jackson path as the config file, so the setter checks the value
    and an unknown field fails startup. Option over variable over file. The
    413 names the limit and where to raise it.
    
    * TIKA-5025: TIKA_SERVER_SETTING_ as the prefix, out of Kubernetes' way
    
    Kubernetes gives every pod TIKA_SERVER_PORT=tcp://..., 
TIKA_SERVER_SERVICE_HOST
    and more for a Service named tika-server; with TIKA_SERVER_ as the prefix
    those would set the port to a URL or name an unknown field, and the server
    would stop starting on upgrade with no config change. The prefix is
    TIKA_SERVER_SETTING_ now, the test puts those variables into the 
environment,
    and the javadoc and the docs say why the prefix is this long.
    
    * TIKA-5025: refuse conflicting and unsupported server settings, pass 
values as text
---
 CHANGES.txt                                        |   6 +
 docs/modules/ROOT/pages/using-tika/docker.adoc     |  12 ++
 .../ROOT/pages/using-tika/server/index.adoc        |  31 +++-
 .../tika/server/core/MaxRequestSizeFilter.java     |  10 +-
 .../org/apache/tika/server/core/TikaServerCli.java |   4 +
 .../apache/tika/server/core/TikaServerConfig.java  | 181 ++++++++++++++++++++-
 .../apache/tika/server/core/TikaServerProcess.java |   4 +
 .../server/core/ServerSettingsIntegrationTest.java |  73 +++++++++
 .../tika/server/core/TikaServerConfigTest.java     | 125 ++++++++++++++
 9 files changed, 434 insertions(+), 12 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index afe22f053a..4b3d75de01 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -20,6 +20,12 @@ Release 4.3.0 - ???
 
   SERVER, PIPES AND OPERATIONS
 
+   * tika-server sets any field of the config's "server" block from the 
command line
+     (--serverSetting <field>=<value>, repeatable) and the environment 
(TIKA_SERVER_SETTING_<FIELD>,
+     e.g. TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES), over the file's own 
value, so raising the
+     1 GiB request body limit no longer needs a config file. The 413 names the 
limit and
+     where to raise it (TIKA-5025).
+     
    * When pipes kills a forked server, it now kills the server's descendant 
processes too,
      such as ExternalParser commands and their children; they kept running. On 
a timeout,
      an interrupt or JVM shutdown, ProcessUtils.execute also kills the 
descendants of the
diff --git a/docs/modules/ROOT/pages/using-tika/docker.adoc 
b/docs/modules/ROOT/pages/using-tika/docker.adoc
index 49acd35233..9e6ef7454d 100644
--- a/docs/modules/ROOT/pages/using-tika/docker.adoc
+++ b/docs/modules/ROOT/pages/using-tika/docker.adoc
@@ -116,6 +116,18 @@ curl -T document.pdf 
http://localhost:9998/unpack/preset/thumbnail
 
 See 
xref:using-tika/server/index.adoc#_presets_named_configuration_without_config[Presets].
 
+Neither does a field of the config's `server` block: 
`TIKA_SERVER_SETTING_<FIELD>` sets it, the name in
+upper snake case. The request body limit is 1 GiB by default, so an audiobook 
or a video over
+that gets a `413` until the limit is raised:
+
+[source,bash]
+----
+docker run -d -p 127.0.0.1:9998:9998 -e TIKA_PRESETS=thumbnail \
+  -e TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES=4294967296 
apache/tika:X.Y.Z-full
+----
+
+See xref:using-tika/server/index.adoc#_configuration[Configuration] for the 
fields.
+
 == Extra jars
 
 Mount a directory of jars at `/tika-extras` — it is already on the container's 
classpath, and the
diff --git a/docs/modules/ROOT/pages/using-tika/server/index.adoc 
b/docs/modules/ROOT/pages/using-tika/server/index.adoc
index 9fd9584443..8e40fc5d9a 100644
--- a/docs/modules/ROOT/pages/using-tika/server/index.adoc
+++ b/docs/modules/ROOT/pages/using-tika/server/index.adoc
@@ -380,13 +380,18 @@ xref:using-tika/server/monitoring.adoc[Monitoring with 
Prometheus].
 |Activate a catalog preset, as `"presets": {"<name>": true}` in the config 
would. Repeatable.
 The environment variable `TIKA_PRESETS` takes a comma-separated list. See 
<<_presets_named_configuration_without_config,Presets>>.
 
+|`--serverSetting <field>=<value>`
+|Set a field of the config's `server` block, e.g. `--serverSetting 
maxRequestSizeBytes=4294967296`.
+Repeatable. The environment variable `TIKA_SERVER_SETTING_<FIELD>` sets the 
same field, the name in upper
+snake case: `TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES`. See 
<<_configuration,Configuration>>.
+
 |`-?`, `--help`
 |Print the usage message.
 |===
 
-NOTE: `-h`, `-p`, `-i` and `--metricsPort` override the JSON config; 
`--preset` and `TIKA_PRESETS`
-add to its `presets`. Everything else — `allowPipes`, `allowPerRequestConfig`, 
CORS, TLS,
-timeouts — is JSON-only.
+NOTE: `-h`, `-p`, `-i`, `--metricsPort`, `--serverSetting` and 
`TIKA_SERVER_SETTING_<FIELD>` override the
+JSON config's `server` block; `--preset` and `TIKA_PRESETS` add to its 
`presets`. Everything else
+— parsers, detectors, pipes — is JSON-only.
 
 == Endpoints
 
@@ -676,7 +681,25 @@ hard failure.
 == Configuration
 
 Server behavior beyond host/port comes from the `server` section of the JSON 
config passed via
-`-c`/`--config`. That section maps to fields on `TikaServerConfig`:
+`-c`/`--config`. That section maps to fields on `TikaServerConfig`.
+
+Every field except `tlsConfig` and `endpoints`, which take an object and a 
list and stay in the
+file, can also be set without it: `--serverSetting <field>=<value>` 
(repeatable) or the environment
+variable `TIKA_SERVER_SETTING_<FIELD>`, the field's name in upper snake case 
(the prefix is
+deliberately not `TIKA_SERVER_`, which Kubernetes uses for the variables it 
gives every pod for a
+Service named `tika-server`). Either goes through the same binding as the 
file, so the value is
+checked as it would be there, an unknown field fails startup, and the option 
wins over the
+variable, which wins over the file. Startup also fails when two options or two 
variables set the
+same field, and when a setting names `host`, `port`, `id` or `metricsPort` 
while `-h`, `-p`, `-i` or
+`--metricsPort` sets it too. The Docker image's entry point passes `-h 
0.0.0.0`, so
+`TIKA_SERVER_SETTING_HOST` fails there; limit access with the container's port 
mapping instead.
+`presets` is set with `--preset` or `TIKA_PRESETS`.
+
+[source,bash]
+----
+java -jar tika-server-standard-X.Y.Z.jar --serverSetting 
maxRequestSizeBytes=4294967296
+TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES=4294967296 java -jar 
tika-server-standard-X.Y.Z.jar
+----
 
 [cols="1,1,3"]
 |===
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
index 983da171e2..aea9c628a7 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/MaxRequestSizeFilter.java
@@ -46,6 +46,8 @@ import org.apache.cxf.jaxrs.utils.JAXRSUtils;
 public class MaxRequestSizeFilter implements ContainerRequestFilter {
 
     static final String TOO_LARGE_MESSAGE = "Request body exceeds 
maxRequestSizeBytes";
+    static final String RAISE_HINT = "; raise it in the config's server block 
or with "
+            + TikaServerConfig.SETTINGS_ENV_PREFIX + "MAX_REQUEST_SIZE_BYTES";
     static final String TOO_SLOW_MESSAGE = "Request body not received within 
maxRequestReadMillis";
     static final String IDLE_MESSAGE = "Request body stalled past 
idleTimeoutMillis";
     static final String BUSY_MESSAGE = "Request body would exceed 
maxInFlightRequestBytes across requests in flight";
@@ -76,7 +78,7 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
         if (maxRequestSizeBytes >= 0 && requestContext.getLength() > 
maxRequestSizeBytes) {
             TransferLimitLog.log("declared Content-Length " + 
requestContext.getLength()
                     + " exceeds maxRequestSizeBytes (" + maxRequestSizeBytes + 
")");
-            requestContext.abortWith(tooLarge());
+            requestContext.abortWith(tooLarge(maxRequestSizeBytes));
             return;
         }
         RequestBodyBudget.Reservation reservation =
@@ -86,10 +88,10 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
                 new BoundedInputStream(requestContext.getEntityStream(), 
maxRequestSizeBytes, reservation));
     }
 
-    private static Response tooLarge() {
+    private static Response tooLarge(long limit) {
         return Response
                 .status(Response.Status.REQUEST_ENTITY_TOO_LARGE)
-                .entity(TOO_LARGE_MESSAGE)
+                .entity(TOO_LARGE_MESSAGE + " (" + limit + " bytes)" + 
RAISE_HINT)
                 .type(MediaType.TEXT_PLAIN)
                 .build();
     }
@@ -203,7 +205,7 @@ public class MaxRequestSizeFilter implements 
ContainerRequestFilter {
         private void add(int n) {
             count += n;
             if (limit >= 0 && count > limit) {
-                throw new RequestTooLargeException(TOO_LARGE_MESSAGE + " (" + 
limit + " bytes)");
+                throw new RequestTooLargeException(TOO_LARGE_MESSAGE + " (" + 
limit + " bytes)" + RAISE_HINT);
             }
             if (reservation != null && !reservation.ensure(count)) {
                 throw new RequestBudgetExceededException(BUSY_MESSAGE);
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
index 1adb5ed060..ff9cc0e215 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerCli.java
@@ -45,6 +45,10 @@ public class TikaServerCli {
         options.addOption(null, "preset", true,
                 "activate a catalog preset, as \"presets\": {\"<name>\": true} 
in the config "
                         + "would; repeatable, and TIKA_PRESETS takes a 
comma-separated list");
+        options.addOption(null, TikaServerConfig.SETTING_OPTION, true,
+                "set a field of the config's \"server\" block, 
<field>=<value>, e.g. "
+                        + "maxRequestSizeBytes=4294967296; repeatable, and 
TIKA_SERVER_SETTING_<FIELD> "
+                        + "in the environment sets the same 
(TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES)");
         return options;
     }
 
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
index 4d405f9915..05bcb92f63 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerConfig.java
@@ -24,17 +24,23 @@ import java.util.Arrays;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 
+import com.fasterxml.jackson.databind.JsonMappingException;
 import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.apache.commons.cli.CommandLine;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import org.apache.tika.config.loader.TikaJsonConfig;
 import org.apache.tika.config.loader.TikaLoader;
+import org.apache.tika.config.loader.TikaObjectMapperFactory;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.pipes.core.PipesConfig;
@@ -49,6 +55,36 @@ public class TikaServerConfig {
 
     /** Comma-separated catalog presets to activate, read at startup like 
{@code --preset}. */
     public static final String PRESETS_ENV = "TIKA_PRESETS";
+
+    /**
+     * Prefix of the environment variables that set a field of the {@code 
server} block:
+     * {@code TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES=4294967296} is {@code 
"maxRequestSizeBytes":
+     * 4294967296}, read at startup like {@code --serverSetting 
maxRequestSizeBytes=4294967296}.
+     * Not {@code TIKA_SERVER_}: Kubernetes gives every pod {@code 
TIKA_SERVER_PORT},
+     * {@code TIKA_SERVER_SERVICE_HOST} and more for a Service named {@code 
tika-server}, and
+     * those must neither set a field nor fail the start.
+     */
+    public static final String SETTINGS_ENV_PREFIX = "TIKA_SERVER_SETTING_";
+
+    /** The {@code --serverSetting <field>=<value>} option, repeatable. */
+    static final String SETTING_OPTION = "serverSetting";
+
+    private static final ObjectMapper OBJECT_MAPPER = 
TikaObjectMapperFactory.getMapper();
+
+    /** A value for a {@code server} field and what set it, for the log and 
the error. */
+    private record Setting(String value, String source) {
+    }
+
+    /** Fields that are not settings, and where they are set instead. */
+    private static final Map<String, String> NOT_SETTINGS = Map.of(
+            "configPath", "the -c option",
+            "presets", "--preset or " + PRESETS_ENV,
+            "tlsConfig", "the config file, it takes an object",
+            "endpoints", "the config file, it takes a list");
+
+    /** Fields that have an option of their own, and that option. */
+    private static final Map<String, String> OWN_OPTIONS = Map.of(
+            "port", "p", "host", "h", "id", "i", "metricsPort", "metricsPort");
     /**
      * Endpoints that expose the pipes/fetch machinery (process-isolated pipes
      * parsing and async batch processing). Selecting any of these requires
@@ -113,11 +149,17 @@ public class TikaServerConfig {
     }
 
     public static TikaServerConfig load(CommandLine commandLine) throws 
IOException, TikaException {
-        return load(commandLine, System.getenv(PRESETS_ENV));
+        return load(commandLine, System.getenv(PRESETS_ENV), System.getenv());
     }
 
     static TikaServerConfig load(CommandLine commandLine, String presetsEnv)
             throws IOException, TikaException {
+        return load(commandLine, presetsEnv, Map.of());
+    }
+
+    static TikaServerConfig load(CommandLine commandLine, String presetsEnv,
+                                 Map<String, String> environment)
+            throws IOException, TikaException {
 
         Map<String, String> presets = new LinkedHashMap<>();
         if (commandLine.hasOption("preset")) {
@@ -133,15 +175,26 @@ public class TikaServerConfig {
             }
         }
 
+        Map<String, Setting> serverSettings = serverSettings(commandLine, 
environment);
+        for (Map.Entry<String, String> own : OWN_OPTIONS.entrySet()) {
+            Setting setting = serverSettings.get(own.getKey());
+            if (setting != null && commandLine.hasOption(own.getValue())) {
+                String option = own.getValue().length() == 1 ? "-" + 
own.getValue() : "--" + own.getValue();
+                throw new TikaConfigException(setting.source() + " and " + 
option + " both set "
+                        + own.getKey() + "; use one of them");
+            }
+        }
+
         TikaServerConfig config = null;
         Set<String> settings = new HashSet<>();
 
         if (commandLine.hasOption("c")) {
             config = load(Paths.get(commandLine.getOptionValue("c")), 
commandLine, settings,
-                    presets);
+                    presets, serverSettings);
         } else {
             config = new TikaServerConfig();
             presets.forEach((name, source) -> LOG.info("preset '{}' activated 
by {}", name, source));
+            applySettings(config, serverSettings, null, null);
         }
         config.presets = new ArrayList<>(presets.keySet());
 
@@ -176,11 +229,12 @@ public class TikaServerConfig {
     }
 
     static TikaServerConfig load(Path tikaConfigPath, CommandLine commandLine, 
Set<String> settings) throws IOException, TikaException {
-        return load(tikaConfigPath, commandLine, settings, Map.of());
+        return load(tikaConfigPath, commandLine, settings, Map.of(), Map.of());
     }
 
     private static TikaServerConfig load(Path tikaConfigPath, CommandLine 
commandLine,
-                                         Set<String> settings, Map<String, 
String> presets)
+                                         Set<String> settings, Map<String, 
String> presets,
+                                         Map<String, Setting> serverSettings)
             throws IOException, TikaException {
         TikaJsonConfig tikaJsonConfig = 
TikaLoader.load(tikaConfigPath).getConfig();
         JsonNode filePresets = tikaJsonConfig.getRootNode().get("presets");
@@ -201,6 +255,8 @@ public class TikaServerConfig {
             // no "server" block: same as {}, so a tika-app config works as is
             tikaServerConfig = new TikaServerConfig();
         }
+        applySettings(tikaServerConfig, serverSettings,
+                tikaJsonConfig.getRootNode().get("server"), tikaConfigPath);
         PipesConfig pipesConfig = tikaJsonConfig.deserialize("pipes", 
PipesConfig.class);
         if (pipesConfig != null) {
             pipesConfig.refuseTrustedRequests("tika-server");
@@ -209,6 +265,123 @@ public class TikaServerConfig {
         return tikaServerConfig;
     }
 
+    /**
+     * The {@code server} fields given as {@code --serverSetting 
<field>=<value>} or as
+     * {@value #SETTINGS_ENV_PREFIX}{@code <FIELD>} in the environment, the 
option winning over
+     * the variable for the same field. {@code 
TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES} names
+     * {@code maxRequestSizeBytes}; the variable's name is upper snake case of 
the field's. Two
+     * options, or two variables, for the same field are refused: which one 
wins would be
+     * arbitrary for variables, whose order the environment does not define.
+     */
+    private static Map<String, Setting> serverSettings(CommandLine commandLine,
+                                                       Map<String, String> 
environment)
+            throws TikaConfigException {
+        Map<String, Setting> serverSettings = new LinkedHashMap<>();
+        if (commandLine.hasOption(SETTING_OPTION)) {
+            for (String option : commandLine.getOptionValues(SETTING_OPTION)) {
+                int eq = option.indexOf('=');
+                if (eq < 1) {
+                    throw new TikaConfigException("--" + SETTING_OPTION + " 
takes <field>=<value>, got '"
+                            + option + "'");
+                }
+                put(serverSettings, option.substring(0, eq).trim(),
+                        new Setting(option.substring(eq + 1), "--" + 
SETTING_OPTION));
+            }
+        }
+        Map<String, Setting> fromEnvironment = new LinkedHashMap<>();
+        for (Map.Entry<String, String> e : environment.entrySet()) {
+            String key = e.getKey();
+            if (!key.startsWith(SETTINGS_ENV_PREFIX) || key.length() == 
SETTINGS_ENV_PREFIX.length()) {
+                continue;
+            }
+            put(fromEnvironment, 
fieldName(key.substring(SETTINGS_ENV_PREFIX.length())),
+                    new Setting(e.getValue(), key));
+        }
+        fromEnvironment.forEach(serverSettings::putIfAbsent);
+        return serverSettings;
+    }
+
+    private static void put(Map<String, Setting> settings, String field, 
Setting setting)
+            throws TikaConfigException {
+        Setting earlier = settings.putIfAbsent(field, setting);
+        if (earlier != null) {
+            throw new 
TikaConfigException(earlier.source().equals(setting.source())
+                    ? setting.source() + " sets " + field + " twice; use one 
of them"
+                    : earlier.source() + " and " + setting.source() + " both 
set " + field
+                            + "; use one of them");
+        }
+    }
+
+    /** {@code MAX_REQUEST_SIZE_BYTES} to {@code maxRequestSizeBytes}. */
+    static String fieldName(String upperSnake) {
+        StringBuilder field = new StringBuilder();
+        for (String part : upperSnake.toLowerCase(Locale.ROOT).split("_")) {
+            if (part.isEmpty()) {
+                continue;
+            }
+            field.append(field.isEmpty() ? part
+                    : Character.toUpperCase(part.charAt(0)) + 
part.substring(1));
+        }
+        return field.toString();
+    }
+
+    /**
+     * Sets the fields through the same Jackson binding the config file goes 
through, so a
+     * value is checked by the field's setter and an unknown field fails 
startup, as it does
+     * in the file. {@code fileServer} is the file's {@code server} block, for 
the log.
+     */
+    private static void applySettings(TikaServerConfig config, Map<String, 
Setting> serverSettings,
+                                      JsonNode fileServer, Path tikaConfigPath)
+            throws TikaConfigException {
+        if (serverSettings.isEmpty()) {
+            return;
+        }
+        for (Map.Entry<String, String> notSetting : NOT_SETTINGS.entrySet()) {
+            Setting setting = serverSettings.get(notSetting.getKey());
+            if (setting != null) {
+                throw new TikaConfigException(setting.source() + ": " + 
notSetting.getKey()
+                        + " is not a server setting, it is set with " + 
notSetting.getValue());
+            }
+        }
+        ObjectNode node = OBJECT_MAPPER.createObjectNode();
+        serverSettings.forEach((field, setting) -> {
+            JsonNode inFile = fileServer == null ? null : 
fileServer.get(field);
+            if (inFile == null) {
+                LOG.info("server setting '{}' = {} set by {}", field, 
setting.value(),
+                        setting.source());
+            } else {
+                LOG.warn("server setting '{}' = {} set by {}: overrides 
\"{}\": {} in {}", field,
+                        setting.value(), setting.source(), field, inFile, 
tikaConfigPath);
+            }
+            //a string, as Jackson converts it to the field's type itself: a 
number for a
+            //number field, but "007" stays "007" for a text field
+            node.put(field, setting.value().trim());
+        });
+        try {
+            OBJECT_MAPPER.readerForUpdating(config).readValue(node);
+        } catch (UnrecognizedPropertyException e) {
+            Setting setting = serverSettings.get(e.getPropertyName());
+            List<String> known = new ArrayList<>();
+            for (Object id : e.getKnownPropertyIds()) {
+                if (!NOT_SETTINGS.containsKey(id.toString())) {
+                    known.add(id.toString());
+                }
+            }
+            throw new TikaConfigException(setting.source() + ": the server 
block has no field '"
+                    + e.getPropertyName() + "'; it has " + known, e);
+        } catch (JsonMappingException e) {
+            String field = e.getPath().isEmpty() ? null : 
e.getPath().get(0).getFieldName();
+            Setting setting = field == null ? null : serverSettings.get(field);
+            Throwable cause = e.getCause() == null ? e : e.getCause();
+            throw new TikaConfigException((setting == null ? "server setting"
+                    : setting.source() + ": '" + setting.value() + "' for " + 
field)
+                    + " is not valid: " + cause.getMessage(), e);
+        } catch (IOException e) {
+            throw new TikaConfigException("could not apply the server 
settings", e);
+        }
+    }
+
+
 
     public int getPort() {
         return port;
diff --git 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
index 2d00692f78..b65e08e2b4 100644
--- 
a/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
+++ 
b/tika-server/tika-server-core/src/main/java/org/apache/tika/server/core/TikaServerProcess.java
@@ -127,6 +127,10 @@ public class TikaServerProcess {
         options.addOption(null, "preset", true,
                 "activate a catalog preset, as \"presets\": {\"<name>\": true} 
in the config "
                         + "would; repeatable, and TIKA_PRESETS takes a 
comma-separated list");
+        options.addOption(null, TikaServerConfig.SETTING_OPTION, true,
+                "set a field of the config's \"server\" block, 
<field>=<value>, e.g. "
+                        + "maxRequestSizeBytes=4294967296; repeatable, and 
TIKA_SERVER_SETTING_<FIELD> "
+                        + "in the environment sets the same 
(TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES)");
         options.addOption("?", "help", false, "this help message");
         return options;
     }
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ServerSettingsIntegrationTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ServerSettingsIntegrationTest.java
new file mode 100644
index 0000000000..d3b2eec96c
--- /dev/null
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/ServerSettingsIntegrationTest.java
@@ -0,0 +1,73 @@
+/*
+ * 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 org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.ws.rs.core.Response;
+import org.apache.cxf.jaxrs.client.WebClient;
+import org.junit.jupiter.api.Test;
+
+/**
+ * A {@code server} field set with {@code TIKA_SERVER_SETTING_<FIELD>} or 
{@code --serverSetting}
+ * instead of the config file, over the file's own value.
+ */
+public class ServerSettingsIntegrationTest extends IntegrationTestBase {
+
+    private static final String LIMIT_ENV =
+            TikaServerConfig.SETTINGS_ENV_PREFIX + "MAX_REQUEST_SIZE_BYTES";
+
+    @Test
+    public void testEnvironmentVariable() throws Exception {
+        startProcess(new String[]{"-config", 
getConfig("tika-config-server-basic.json")},
+                Map.of(LIMIT_ENV, "10"));
+        assertLimitIs10();
+    }
+
+    @Test
+    public void testServerSettingOption() throws Exception {
+        startProcess(new String[]{"-config", 
getConfig("tika-config-server-basic.json"),
+                "--" + TikaServerConfig.SETTING_OPTION, 
"maxRequestSizeBytes=10"},
+                Map.of(LIMIT_ENV, "1000000"));
+        assertLimitIs10();
+    }
+
+    @Test
+    public void testUnknownFieldFailsStartup() throws Exception {
+        startProcess(new String[]{"-config", 
getConfig("tika-config-server-basic.json")},
+                Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + "NO_SUCH_FIELD", 
"1"));
+        assertTrue(process.waitFor(MAX_WAIT_MS, TimeUnit.MILLISECONDS), 
"server should exit");
+        assertNotEquals(0, process.exitValue());
+    }
+
+    private void assertLimitIs10() throws Exception {
+        awaitServerStartup();
+        Response response = WebClient
+                .create(endPoint + RMETA_PATH)
+                .accept("application/json")
+                .put(ClassLoader.getSystemResourceAsStream(TEST_HELLO_WORLD));
+        assertEquals(413, response.getStatus());
+        String body = response.readEntity(String.class);
+        assertContains("(10 bytes)", body);
+        assertContains(LIMIT_ENV, body);
+    }
+}
diff --git 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
index dd91063835..12d568a4e9 100644
--- 
a/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
+++ 
b/tika-server/tika-server-core/src/test/java/org/apache/tika/server/core/TikaServerConfigTest.java
@@ -27,6 +27,7 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 import com.fasterxml.jackson.databind.JsonNode;
@@ -155,6 +156,130 @@ public class TikaServerConfigTest extends TikaTest {
         assertTrue(config.getPresets().isEmpty());
     }
 
+    @Test
+    public void testServerSettingsFromCommandLineAndEnvironment() throws 
Exception {
+        Options options = new Options()
+                
.addOption(Option.builder().longOpt(TikaServerConfig.SETTING_OPTION).hasArg().get());
+        CommandLine commandLine = new DefaultParser().parse(options, new 
String[]{
+                "--" + TikaServerConfig.SETTING_OPTION, 
"maxRequestSizeBytes=4294967296",
+                "--" + TikaServerConfig.SETTING_OPTION, "allowHttp2=true"});
+        Map<String, String> env = Map.of(
+                TikaServerConfig.SETTINGS_ENV_PREFIX + 
"MAX_REQUEST_SIZE_BYTES", "1",
+                TikaServerConfig.SETTINGS_ENV_PREFIX + 
"MAX_REQUEST_READ_MILLIS", " 7000 ",
+                TikaServerConfig.SETTINGS_ENV_PREFIX + "REQUEST_LOG_LEVEL", 
"debug",
+                "TIKA_SERVER", "ignored",
+                "TIKA_VERSION", "ignored",
+                // what Kubernetes gives every pod for a Service named 
tika-server
+                "TIKA_SERVER_PORT", "tcp://10.0.0.1:9998",
+                "TIKA_SERVER_SERVICE_HOST", "10.0.0.1",
+                "TIKA_SERVER_PORT_9998_TCP_ADDR", "10.0.0.1");
+
+        TikaServerConfig config = TikaServerConfig.load(commandLine, null, 
env);
+        assertEquals(4294967296L, config.getMaxRequestSizeBytes(), "the option 
wins over the variable");
+        assertTrue(config.isAllowHttp2());
+        assertEquals(7000L, config.getMaxRequestReadMillis());
+        assertEquals(TikaServerConfig.DEFAULT_PORT, config.getPort(), 
"Kubernetes variables are not settings");
+        assertEquals("debug", config.getRequestLogLevel());
+
+        config = TikaServerConfig.load(new DefaultParser().parse(options, new 
String[]{}), null, env);
+        assertEquals(1L, config.getMaxRequestSizeBytes());
+    }
+
+    @Test
+    public void testServerSettingsOverrideTheFile(@TempDir Path dir) throws 
Exception {
+        Path path = Files.writeString(dir.resolve("limits.json"),
+                "{\"server\": {\"maxRequestSizeBytes\": 1000, 
\"maxRequestReadMillis\": 2000}}");
+        Options options = new 
Options().addOption(Option.builder("c").longOpt("config").hasArg().get());
+        CommandLine commandLine = new DefaultParser().parse(options,
+                new String[]{"-c", 
ProcessUtils.escapeCommandLine(path.toAbsolutePath().toString())});
+
+        TikaServerConfig config = TikaServerConfig.load(commandLine, null,
+                Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"MAX_REQUEST_SIZE_BYTES", "3000"));
+        assertEquals(3000L, config.getMaxRequestSizeBytes());
+        assertEquals(2000L, config.getMaxRequestReadMillis(), "the file keeps 
what nothing overrides");
+    }
+
+    @Test
+    public void testServerSettingsAreChecked() throws Exception {
+        CommandLine empty = new DefaultParser().parse(new Options(), new 
String[]{});
+
+        TikaConfigException ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                empty, null, Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"MAX_REQUEST_BYTES", "1")));
+        assertContains("TIKA_SERVER_SETTING_MAX_REQUEST_BYTES", 
ex.getMessage());
+        assertContains("no field 'maxRequestBytes'", ex.getMessage());
+        assertContains("maxRequestSizeBytes", ex.getMessage());
+
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                empty, null, Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"MAX_REQUEST_SIZE_BYTES", "4g")));
+        assertContains("TIKA_SERVER_SETTING_MAX_REQUEST_SIZE_BYTES", 
ex.getMessage());
+        assertContains("'4g' for maxRequestSizeBytes", ex.getMessage());
+
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                empty, null, Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"REQUEST_LOG_LEVEL", "loud")));
+        assertContains("requestLogLevel must be one of", ex.getMessage());
+
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                empty, null, Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"CONFIG_PATH", "/x.json")));
+        assertContains("-c option", ex.getMessage());
+
+        Options options = new Options()
+                
.addOption(Option.builder().longOpt(TikaServerConfig.SETTING_OPTION).hasArg().get());
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                new DefaultParser().parse(options,
+                        new String[]{"--" + TikaServerConfig.SETTING_OPTION, 
"maxRequestSizeBytes"}),
+                null, Map.of()));
+        assertContains("<field>=<value>", ex.getMessage());
+    }
+
+    @Test
+    public void testServerSettingsAreText() throws Exception {
+        CommandLine empty = new DefaultParser().parse(new Options(), new 
String[]{});
+        TikaServerConfig config = TikaServerConfig.load(empty, null, Map.of(
+                TikaServerConfig.SETTINGS_ENV_PREFIX + "ID", "007",
+                TikaServerConfig.SETTINGS_ENV_PREFIX + "ALLOW_HTTP2", "TRUE"));
+        assertEquals("007", config.getId(), "a text field keeps its leading 
zeros");
+        assertTrue(config.isAllowHttp2());
+    }
+
+    @Test
+    public void testServerSettingConflicts() throws Exception {
+        Options options = new Options()
+                .addOption(Option.builder("h").hasArg().get())
+                
.addOption(Option.builder().longOpt(TikaServerConfig.SETTING_OPTION).hasArg().get());
+        CommandLine host = new DefaultParser().parse(options, new 
String[]{"-h", "0.0.0.0"});
+        TikaConfigException ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(
+                host, null, Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + 
"HOST", "127.0.0.1")));
+        assertContains("TIKA_SERVER_SETTING_HOST and -h both set host", 
ex.getMessage());
+
+        CommandLine empty = new DefaultParser().parse(options, new String[]{});
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(empty, null, Map.of(
+                TikaServerConfig.SETTINGS_ENV_PREFIX + "ALLOW_PIPES", "true",
+                TikaServerConfig.SETTINGS_ENV_PREFIX + "ALLOW__PIPES", 
"false")));
+        assertContains("both set allowPipes", ex.getMessage());
+
+        CommandLine twice = new DefaultParser().parse(options, new String[]{
+                "--" + TikaServerConfig.SETTING_OPTION, "allowPipes=true",
+                "--" + TikaServerConfig.SETTING_OPTION, "allowPipes=false"});
+        ex = assertThrows(TikaConfigException.class,
+                () -> TikaServerConfig.load(twice, null, Map.of()));
+        assertContains("sets allowPipes twice", ex.getMessage());
+
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(empty, null,
+                Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + "PRESETS", 
"thumbnail")));
+        assertContains("TIKA_PRESETS", ex.getMessage());
+
+        ex = assertThrows(TikaConfigException.class, () -> 
TikaServerConfig.load(empty, null,
+                Map.of(TikaServerConfig.SETTINGS_ENV_PREFIX + "TLS_CONFIG", 
"x")));
+        assertContains("config file", ex.getMessage());
+    }
+
+    @Test
+    public void testFieldName() {
+        assertEquals("maxRequestSizeBytes", 
TikaServerConfig.fieldName("MAX_REQUEST_SIZE_BYTES"));
+        assertEquals("port", TikaServerConfig.fieldName("PORT"));
+        assertEquals("allowHttp2", TikaServerConfig.fieldName("ALLOW_HTTP2"));
+    }
+
     @Test
     public void testConfigPathWithPresets() throws Exception {
         Path path = getConfigPath(getClass(), "tika-config-server.json");

Reply via email to