diff --git a/.github/project.yml b/.github/project.yml
index a67679ca..a4f24a11 100644
--- a/.github/project.yml
+++ b/.github/project.yml
@@ -1,4 +1,4 @@
release:
previous-version: 0.6.6
- current-version: 0.6.7
- next-version: 0.6.8
+ current-version: 0.7.0
+ next-version: 0.7.1
diff --git a/CLAUDE.md b/CLAUDE.md
index f988587b..e13e1e4a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,14 +28,14 @@ This is a multi-module Gradle project (Kotlin DSL) with group `ai.docling`. Vers
### Modules
- **`docling-core`**: Java types mirroring the [docling-core](https://github.com/docling-project/docling-core) Python library's document representation model. Uses Lombok and JSpecify. Jackson is a `compileOnly` dependency — consumers must bring their own.
-- **`docling-serve-api`** (`docling-serve/docling-serve-api`): Framework-agnostic API interfaces for interacting with a [Docling Serve](https://github.com/docling-project/docling-serve) backend. Defines `DoclingServeApi` (extends `DoclingServeHealthApi`, `DoclingServeConvertApi`, `DoclingServeChunkApi`, `DoclingServeClearApi`, `DoclingServeTaskApi`) and the SPI interface `DoclingServeApiBuilderFactory` used for discovery via `java.util.ServiceLoader`.
+- **`docling-serve-api`** (`docling-serve/docling-serve-api`): Framework-agnostic API interfaces for interacting with a [Docling Serve](https://github.com/docling-project/docling-serve) backend. Defines `DoclingServeApi` (extends `DoclingServeHealthApi`, `DoclingServeConvertApi`, `DoclingServeChunkApi`, `DoclingServeClearApi`, `DoclingServeTaskApi`) and the SPI interface `DoclingServeApiProvider` used for discovery via `java.util.ServiceLoader` (the older `DoclingServeApiBuilderFactory` is deprecated and only used as a fallback).
- **`docling-serve-client`** (`docling-serve/docling-serve-client`): Reference implementation using Java's `HttpClient`. Provides `DoclingServeJackson2Client` and `DoclingServeJackson3Client` — concrete implementations for Jackson 2.x and 3.x respectively. `DoclingServeClient` (abstract) contains all HTTP logic.
- **`docling-testcontainers`**: Testcontainers module exposing `DoclingServeContainer` for spinning up a Docling Serve Docker container in tests.
- **`docling-testing/docling-version-tests`**: Internal tooling for running compatibility tests across Docling Serve container versions.
### Key Design Patterns
-**SPI for client discovery**: `DoclingServeApi.builder()` uses `ServiceLoader` to discover a `DoclingServeApiBuilderFactory`. Exactly one implementation must be on the classpath — having zero or more than one throws `IllegalStateException`. The `docling-serve-client` module registers itself as the factory.
+**SPI for client discovery**: `DoclingServeApi.builder()` returns a `DoclingServeApiBuilder` that collects an immutable `DoclingServeApiConfig`; `build()` uses `ServiceLoader` to discover a `DoclingServeApiProvider` and calls `create(config)`. Exactly one provider must be on the classpath — zero or more than one throws `IllegalStateException`. If no provider is found, deprecated `DoclingServeApiBuilderFactory` implementations are adapted as a fallback. Providers declare options they cannot honor via `unsupportedOptions()`; new options are added to `DoclingServeApiConfig` (a constant in `ALL_OPTIONS`, an accessor and a builder setter — guarded by `DoclingServeApiConfigTests`), never to the deprecated `DoclingApiBuilder`. Every `DoclingServeApi` implements the abstract `config()`, which must report the effective value of every option; `DoclingServeApi.toBuilder()` is deprecated in favor of `config().toBuilder()`. The `docling-serve-client` module registers `DoclingServeClientProvider`.
**Dual Jackson support**: All Jackson dependencies are `compileOnly` in production code. Jackson 2.x and 3.x are both supported through parallel implementations. Consumers must include one Jackson version on their classpath.
diff --git a/docling-serve/docling-serve-api/build.gradle.kts b/docling-serve/docling-serve-api/build.gradle.kts
index 9d9341d2..de8dda68 100644
--- a/docling-serve/docling-serve-api/build.gradle.kts
+++ b/docling-serve/docling-serve-api/build.gradle.kts
@@ -18,9 +18,11 @@ nativeImageMetadata {
dependencies {
api(project(":docling-core"))
+ api(libs.slf4j.api)
compileOnly(platform(libs.jackson.bom))
compileOnly(libs.jackson.annotations)
compileOnly(libs.jackson.databind)
compileOnly(libs.jackson2.databind)
testImplementation(project(":docling-testcontainers"))
+ testImplementation(libs.slf4j.simple)
}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/ConfigOption.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/ConfigOption.java
new file mode 100644
index 00000000..b4928c1b
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/ConfigOption.java
@@ -0,0 +1,63 @@
+package ai.docling.serve.api;
+
+import static ai.docling.serve.api.util.ValidationUtils.ensureNotBlank;
+import static ai.docling.serve.api.util.ValidationUtils.ensureNotNull;
+
+/**
+ * A typed key identifying a single configuration option of a {@link DoclingServeApiConfig}.
+ *
+ *
The set of options is closed: instances can only be created by the {@code ai.docling.serve.api}
+ * package, and every available option is exposed as a {@code public static final} constant on
+ * {@link DoclingServeApiConfig}. Options are compared by identity.
+ *
+ *
Options are primarily useful to implementors of {@link ai.docling.serve.api.spi.DoclingServeApiProvider}, to declare
+ * which options they do not honor via {@link ai.docling.serve.api.spi.DoclingServeApiProvider#unsupportedOptions()}, and to
+ * check whether a caller explicitly set an option via {@link DoclingServeApiConfig#isExplicitlySet(ConfigOption)}.
+ *
+ * @param the type of the option's value
+ */
+public final class ConfigOption {
+ private final String name;
+ private final Class type;
+
+ private ConfigOption(String name, Class type) {
+ this.name = ensureNotBlank(name, "name");
+ this.type = ensureNotNull(type, "type");
+ }
+
+ /**
+ * Creates a new option. Package-private so that the set of options stays closed.
+ *
+ * @param name the name of the option, matching the accessor on {@link DoclingServeApiConfig}
+ * @param type the type of the option's value
+ * @param the type of the option's value
+ * @return a new option
+ */
+ static ConfigOption of(String name, Class type) {
+ return new ConfigOption<>(name, type);
+ }
+
+ /**
+ * The name of this option. It matches the name of the corresponding accessor on
+ * {@link DoclingServeApiConfig} and of the corresponding setter on {@link DoclingServeApiBuilder}.
+ *
+ * @return the name of this option
+ */
+ public String name() {
+ return this.name;
+ }
+
+ /**
+ * The type of this option's value.
+ *
+ * @return the type of this option's value
+ */
+ public Class type() {
+ return this.type;
+ }
+
+ @Override
+ public String toString() {
+ return this.name;
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java
index 491f6fac..c60bc68d 100644
--- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java
@@ -4,59 +4,59 @@
import java.net.URI;
import java.time.Duration;
-import java.util.concurrent.Executor;
-import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
-import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
-import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
-import ai.docling.serve.api.spi.ServiceLoaderHelper;
-
/**
* Docling Serve API interface.
*/
public interface DoclingServeApi extends DoclingServeHealthApi, DoclingServeConvertApi, DoclingServeChunkApi, DoclingServeClearApi, DoclingServeTaskApi {
/**
- * Creates and returns a builder instance capable of constructing implementations of {@link DoclingServeApi}.
- * The method ensures that exactly one factory capable of building a builder instance is available
- * via the {@link DoclingServeApiBuilderFactory} interface.
+ * Creates a new builder for a {@link DoclingServeApi}.
*
- * If no factories are found, or if multiple factories are found, an {@link IllegalStateException} is thrown.
+ * Calling {@link DoclingServeApiBuilder#build()} creates the API using the single
+ * {@link ai.docling.serve.api.spi.DoclingServeApiProvider} available through {@link java.util.ServiceLoader}. If none is
+ * available, it falls back to the deprecated {@link ai.docling.serve.api.spi.DoclingServeApiBuilderFactory} SPI.
*
- * @param the type of the {@link DoclingServeApi} implementation being built
- * @param the type of the builder implementation for the {@link DoclingServeApi}
- * @return a builder instance of type {@code B} constructed using the available factory
- * @throws IllegalStateException if no factories or more than one factory are found
+ * @return a new builder with no option set
*/
- static > B builder() {
- var factories = ServiceLoaderHelper.loadFactories(DoclingServeApiBuilderFactory.class);
-
- if (factories.isEmpty()) {
- // No factory found
- throw new IllegalStateException("No instance of %s found to build a %s instance. You are probably missing a library on your classpath."
- .formatted(DoclingServeApiBuilderFactory.class.getName(), DoclingApiBuilder.class.getName()));
- }
-
- if (factories.size() > 1) {
- // Multiple factories found
- throw new IllegalStateException("Multiple instances of %s found to build a %s instance: [%s]".formatted(DoclingServeApiBuilderFactory.class.getName(), DoclingApiBuilder.class
- .getName(), factories.stream().map(f -> f.getClass().getName()).collect(Collectors.joining(", "))));
- }
-
- // Only 1 factory (what we want)
- return factories.iterator().next().getBuilder();
+ static DoclingServeApiBuilder builder() {
+ return new DoclingServeApiBuilder();
}
+ /**
+ * The configuration this API runs with.
+ *
+ * Use {@code config().toBuilder()} to create a modified copy of this API through the available
+ * {@link ai.docling.serve.api.spi.DoclingServeApiProvider}, for example
+ * {@code api.config().toBuilder().logRequests().build()}.
+ *
+ *
Implementations must report the effective value of every option, so that an API built from the
+ * returned configuration behaves like this one. An implementation created from a
+ * {@link DoclingServeApiConfig} may simply return it.
+ *
+ * @return the configuration of this API
+ */
+ DoclingServeApiConfig config();
+
/**
* Creates and returns a builder instance capable of constructing a duplicate or modified
* version of the current API instance. The builder provides a customizable way to adjust
* configuration or properties before constructing a new API instance.
*
+ * @param the type of the {@link DoclingServeApi} implementation being built
+ * @param the type of the builder implementation
* @return a {@link DoclingApiBuilder} initialized with the state of the current API instance.
+ * @deprecated Use {@code config().toBuilder()} instead, which does not depend on the implementation.
+ * Implementation-specific builders remain available from the {@code toBuilder()} method
+ * of the concrete implementation.
*/
- @SuppressWarnings("unchecked")
+ @Deprecated(since = "0.7.0", forRemoval = true)
+ @SuppressWarnings({
+ "unchecked",
+ "removal"
+ })
> DoclingApiBuilder toBuilder();
/**
@@ -65,7 +65,11 @@ static > B builder(
*
* @param the type of the {@link DoclingServeApi} implementation being built.
* @param the type of the concrete builder implementation.
+ * @deprecated This interface only exists to support the deprecated {@link ai.docling.serve.api.spi.DoclingServeApiBuilderFactory} SPI.
+ * Use {@link DoclingServeApi#builder()} to configure an API, and implement {@link ai.docling.serve.api.spi.DoclingServeApiProvider}
+ * to provide one. This interface will not gain new configuration options.
*/
+ @Deprecated(since = "0.7.0", forRemoval = true)
interface DoclingApiBuilder> {
/**
* Sets the base URL for the client.
@@ -184,7 +188,7 @@ default B prettyPrint() {
* Sets the polling interval for async operations.
*
* This configures how frequently the client will check the status of async
- * conversion tasks when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
+ * conversion tasks when using {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)} (ConvertDocumentRequest)}.
*
* @param asyncPollInterval the polling interval (must not be null or negative)
* @return this builder instance for method chaining
@@ -196,7 +200,7 @@ default B prettyPrint() {
* Sets the timeout for async operations.
*
*
This configures the maximum time to wait for an async conversion task to complete
- * when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest)}.
+ * when using {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)} (ConvertDocumentRequest)}.
*
* @param asyncTimeout the timeout duration (must not be null or negative)
* @return this builder instance for method chaining
@@ -204,31 +208,6 @@ default B prettyPrint() {
*/
B asyncTimeout(Duration asyncTimeout);
- /**
- * Sets the {@link Executor} used to run async operations.
- *
- *
This configures where the work of the async methods (such as
- * {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)}) is executed: submitting
- * the task, polling for its status and retrieving its result. If not set, async operations run
- * on the default async executor of {@link java.util.concurrent.CompletableFuture}.
- *
- *
The lifecycle of the executor is owned by the caller: the client never shuts it down.
- * Avoid direct executors such as {@code Runnable::run}: the blocking HTTP requests would then run
- * on the calling thread, making the async methods partially blocking, and on the shared scheduler
- * thread of {@link java.util.concurrent.CompletableFuture#delayedExecutor(long, java.util.concurrent.TimeUnit, Executor)}.
- *
- *
The default implementation throws {@link UnsupportedOperationException}, so that existing
- * builder implementations keep compiling; builders supporting a custom executor override it.
- *
- * @param asyncExecutor the executor to use for async operations (must not be null)
- * @return this builder instance for method chaining
- * @throws IllegalArgumentException if asyncExecutor is null
- * @throws UnsupportedOperationException if this builder does not support a custom executor
- */
- default B asyncExecutor(Executor asyncExecutor) {
- throw new UnsupportedOperationException("A custom async executor is not supported by " + getClass().getName());
- }
-
/**
* Builds and returns an instance of the specified type, representing the completed configuration
* of the builder. The returned instance is typically an implementation of the Docling API.
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java
new file mode 100644
index 00000000..4dfe2c2f
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java
@@ -0,0 +1,283 @@
+package ai.docling.serve.api;
+
+import static ai.docling.serve.api.util.ValidationUtils.ensureNotBlank;
+import static ai.docling.serve.api.util.ValidationUtils.ensureNotNull;
+import static ai.docling.serve.api.util.ValidationUtils.ensurePositiveDuration;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.EnumMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+import ai.docling.serve.api.spi.DoclingServeApiProvider.Unsupported;
+
+/**
+ * A fluent builder for a {@link DoclingServeApiConfig}, and for a {@link DoclingServeApi} created
+ * from it by the available {@link DoclingServeApiProvider}.
+ *
+ *
Obtain an instance through {@link DoclingServeApi#builder()} or {@link DoclingServeApiConfig#toBuilder()}.
+ * Every setter records its option as explicitly set, which is what {@link #build()} checks against
+ * {@link DoclingServeApiProvider#unsupportedOptions()}.
+ *
+ *
Implementation-specific settings (for example a custom JSON mapper or HTTP client) are not
+ * available here: use the builder of the concrete implementation directly for those.
+ */
+public final class DoclingServeApiBuilder {
+ private static final Logger LOG = LoggerFactory.getLogger(DoclingServeApiBuilder.class);
+
+ private final Map, Object> values;
+
+ DoclingServeApiBuilder() {
+ this(Map.of());
+ }
+
+ DoclingServeApiBuilder(Map, Object> values) {
+ this.values = new LinkedHashMap<>(values);
+ }
+
+ /**
+ * Sets the base URL of the Docling Serve API.
+ *
+ * @param baseUrl the base URL, as a {@code String}
+ * @return this builder
+ * @throws IllegalArgumentException if {@code baseUrl} is null, blank, or not a valid URI
+ * @see DoclingServeApiConfig#BASE_URL
+ */
+ public DoclingServeApiBuilder baseUrl(String baseUrl) {
+ return baseUrl(URI.create(ensureNotBlank(baseUrl, "baseUrl")));
+ }
+
+ /**
+ * Sets the base URL of the Docling Serve API.
+ *
+ * @param baseUrl the base URL
+ * @return this builder
+ * @throws IllegalArgumentException if {@code baseUrl} is null
+ * @see DoclingServeApiConfig#BASE_URL
+ */
+ public DoclingServeApiBuilder baseUrl(URI baseUrl) {
+ return set(DoclingServeApiConfig.BASE_URL, ensureNotNull(baseUrl, "baseUrl"));
+ }
+
+ /**
+ * Sets the API key used to authenticate requests.
+ *
+ * @param apiKey the API key, or {@code null} to unset it
+ * @return this builder
+ * @see DoclingServeApiConfig#API_KEY
+ */
+ public DoclingServeApiBuilder apiKey(@Nullable String apiKey) {
+ return set(DoclingServeApiConfig.API_KEY, apiKey);
+ }
+
+ /**
+ * Enables logging of requests.
+ *
+ * @return this builder
+ * @see DoclingServeApiConfig#LOG_REQUESTS
+ */
+ public DoclingServeApiBuilder logRequests() {
+ return logRequests(true);
+ }
+
+ /**
+ * Sets whether requests are logged.
+ *
+ * @param logRequests {@code true} to log requests
+ * @return this builder
+ * @see DoclingServeApiConfig#LOG_REQUESTS
+ */
+ public DoclingServeApiBuilder logRequests(boolean logRequests) {
+ return set(DoclingServeApiConfig.LOG_REQUESTS, logRequests);
+ }
+
+ /**
+ * Enables logging of responses.
+ *
+ * @return this builder
+ * @see DoclingServeApiConfig#LOG_RESPONSES
+ */
+ public DoclingServeApiBuilder logResponses() {
+ return logResponses(true);
+ }
+
+ /**
+ * Sets whether responses are logged.
+ *
+ * @param logResponses {@code true} to log responses
+ * @return this builder
+ * @see DoclingServeApiConfig#LOG_RESPONSES
+ */
+ public DoclingServeApiBuilder logResponses(boolean logResponses) {
+ return set(DoclingServeApiConfig.LOG_RESPONSES, logResponses);
+ }
+
+ /**
+ * Enables pretty-printing of JSON requests and responses.
+ *
+ * @return this builder
+ * @see DoclingServeApiConfig#PRETTY_PRINT
+ */
+ public DoclingServeApiBuilder prettyPrint() {
+ return prettyPrint(true);
+ }
+
+ /**
+ * Sets whether JSON requests and responses are pretty-printed.
+ *
+ * @param prettyPrint {@code true} to pretty-print JSON
+ * @return this builder
+ * @see DoclingServeApiConfig#PRETTY_PRINT
+ */
+ public DoclingServeApiBuilder prettyPrint(boolean prettyPrint) {
+ return set(DoclingServeApiConfig.PRETTY_PRINT, prettyPrint);
+ }
+
+ /**
+ * Sets the timeout to establish a connection to the Docling Serve API.
+ *
+ * @param connectTimeout the connect timeout
+ * @return this builder
+ * @throws IllegalArgumentException if {@code connectTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#CONNECT_TIMEOUT
+ */
+ public DoclingServeApiBuilder connectTimeout(Duration connectTimeout) {
+ ensurePositiveDuration(connectTimeout, "connectTimeout");
+ return set(DoclingServeApiConfig.CONNECT_TIMEOUT, connectTimeout);
+ }
+
+ /**
+ * Sets the timeout for receiving a response from the Docling Serve API.
+ *
+ * @param readTimeout the read timeout
+ * @return this builder
+ * @throws IllegalArgumentException if {@code readTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#READ_TIMEOUT
+ */
+ public DoclingServeApiBuilder readTimeout(Duration readTimeout) {
+ ensurePositiveDuration(readTimeout, "readTimeout");
+ return set(DoclingServeApiConfig.READ_TIMEOUT, readTimeout);
+ }
+
+ /**
+ * Sets how frequently the status of an async task is polled, for example by
+ * {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)}.
+ *
+ * @param asyncPollInterval the poll interval
+ * @return this builder
+ * @throws IllegalArgumentException if {@code asyncPollInterval} is null, zero or negative
+ * @see DoclingServeApiConfig#ASYNC_POLL_INTERVAL
+ */
+ public DoclingServeApiBuilder asyncPollInterval(Duration asyncPollInterval) {
+ ensurePositiveDuration(asyncPollInterval, "asyncPollInterval");
+ return set(DoclingServeApiConfig.ASYNC_POLL_INTERVAL, asyncPollInterval);
+ }
+
+ /**
+ * Sets the maximum time to wait for an async task to complete, for example in
+ * {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)}.
+ *
+ * @param asyncTimeout the async timeout
+ * @return this builder
+ * @throws IllegalArgumentException if {@code asyncTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#ASYNC_TIMEOUT
+ */
+ public DoclingServeApiBuilder asyncTimeout(Duration asyncTimeout) {
+ ensurePositiveDuration(asyncTimeout, "asyncTimeout");
+ return set(DoclingServeApiConfig.ASYNC_TIMEOUT, asyncTimeout);
+ }
+
+ /**
+ * Sets the {@link Executor} used to run async operations: submitting the task, polling for its
+ * status and retrieving its result. If not set, the default async executor of
+ * {@link java.util.concurrent.CompletableFuture} is used.
+ *
+ * The lifecycle of the executor is owned by the caller: it is never shut down. Avoid direct
+ * executors such as {@code Runnable::run}, which would run blocking HTTP requests on the calling
+ * thread and on the shared scheduler thread of {@link java.util.concurrent.CompletableFuture#delayedExecutor(long, java.util.concurrent.TimeUnit, Executor)}.
+ *
+ * @param asyncExecutor the executor to use for async operations
+ * @return this builder
+ * @throws IllegalArgumentException if {@code asyncExecutor} is null
+ * @see DoclingServeApiConfig#ASYNC_EXECUTOR
+ */
+ public DoclingServeApiBuilder asyncExecutor(Executor asyncExecutor) {
+ return set(DoclingServeApiConfig.ASYNC_EXECUTOR, ensureNotNull(asyncExecutor, "asyncExecutor"));
+ }
+
+ /**
+ * Creates an immutable snapshot of the current configuration. Later changes to this builder do not
+ * affect the returned configuration.
+ *
+ * @return the configuration
+ */
+ public DoclingServeApiConfig config() {
+ return new DoclingServeApiConfig(this.values);
+ }
+
+ /**
+ * Creates a {@link DoclingServeApi} from the current configuration, using the single available
+ * {@link DoclingServeApiProvider}.
+ *
+ *
Before the provider is called, every explicitly set option the provider declares in
+ * {@link DoclingServeApiProvider#unsupportedOptions()} is enforced: {@link Unsupported#WARN} options
+ * are logged, and {@link Unsupported#FAIL} options cause an {@link UnsupportedConfigurationException}.
+ *
+ * @return a new {@link DoclingServeApi}
+ * @throws IllegalStateException if no provider or more than one provider is available
+ * @throws UnsupportedConfigurationException if an explicitly set option is declared {@link Unsupported#FAIL} by the provider
+ */
+ public DoclingServeApi build() {
+ return build(DoclingServeApiProviders.resolve());
+ }
+
+ // Package-private so the enforcement can be tested without the ServiceLoader
+ DoclingServeApi build(DoclingServeApiProvider provider) {
+ var config = config();
+ enforce(provider, config);
+ return provider.create(config);
+ }
+
+ private static void enforce(DoclingServeApiProvider provider, DoclingServeApiConfig config) {
+ var unsupported = provider.unsupportedOptions();
+
+ // Iterate in declaration order so that messages are deterministic
+ var optionsByAction = DoclingServeApiConfig.ALL_OPTIONS
+ .stream()
+ .filter(config::isExplicitlySet)
+ .collect(
+ Collectors.groupingBy(
+ option -> unsupported.getOrDefault(option, Unsupported.IGNORE), () -> new EnumMap>>(Unsupported.class), Collectors.toList()));
+
+ var warnings = optionsByAction.getOrDefault(Unsupported.WARN, List.of());
+ var failures = optionsByAction.getOrDefault(Unsupported.FAIL, List.of());
+
+ if (!warnings.isEmpty()) {
+ LOG.warn(
+ "The following options were explicitly configured but are not supported by {} and will be ignored: [{}]", DoclingServeApiProviders.describe(provider), warnings.stream()
+ .map(ConfigOption::name)
+ .collect(Collectors.joining(", ")));
+ }
+
+ if (!failures.isEmpty()) {
+ throw new UnsupportedConfigurationException(DoclingServeApiProviders.describe(provider), failures);
+ }
+ }
+
+ private DoclingServeApiBuilder set(ConfigOption option, @Nullable T value) {
+ Optional.ofNullable(value)
+ .ifPresentOrElse(v -> this.values.put(option, v), () -> this.values.remove(option));
+
+ return this;
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java
new file mode 100644
index 00000000..f376148d
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java
@@ -0,0 +1,297 @@
+package ai.docling.serve.api;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An immutable snapshot of the configuration used to create a {@link DoclingServeApi}.
+ *
+ * Instances are created through {@link DoclingServeApi#builder()} and {@link DoclingServeApiBuilder#config()}, are returned by {@link DoclingServeApi#config()}, and are handed
+ * to a
+ * {@link ai.docling.serve.api.spi.DoclingServeApiProvider} when {@link DoclingServeApiBuilder#build()} is called. Every option
+ * has a typed key exposed as a {@code public static final} {@link ConfigOption} constant on this class,
+ * and a matching accessor.
+ *
+ *
The configuration records which options were explicitly set by the caller, as opposed to left
+ * at their default value (see {@link #isExplicitlySet(ConfigOption)}). This is what allows a provider
+ * to declare options it does not honor without complaining about options the caller never touched.
+ *
+ *
This class is final and owned by the {@code docling-serve-api} module, so new options can be added
+ * in future releases without breaking providers.
+ */
+public final class DoclingServeApiConfig {
+ /**
+ * The base URL of the Docling Serve API. Defaults to {@code http://localhost:5001}.
+ *
+ *
Providers must honor this option.
+ */
+ public static final ConfigOption BASE_URL = ConfigOption.of("baseUrl", URI.class);
+
+ /**
+ * The API key used to authenticate requests. Not set by default.
+ *
+ * Providers must honor this option when it is set.
+ */
+ public static final ConfigOption API_KEY = ConfigOption.of("apiKey", String.class);
+
+ /**
+ * Whether requests are logged. Defaults to {@code false}.
+ *
+ * Providers may declare this option as unsupported if they have no request logging.
+ */
+ public static final ConfigOption LOG_REQUESTS = ConfigOption.of("logRequests", Boolean.class);
+
+ /**
+ * Whether responses are logged. Defaults to {@code false}.
+ *
+ * Providers may declare this option as unsupported if they have no response logging.
+ */
+ public static final ConfigOption LOG_RESPONSES = ConfigOption.of("logResponses", Boolean.class);
+
+ /**
+ * Whether JSON requests and responses are pretty-printed. Defaults to {@code false}.
+ *
+ * Providers may declare this option as unsupported if they cannot control JSON formatting.
+ */
+ public static final ConfigOption PRETTY_PRINT = ConfigOption.of("prettyPrint", Boolean.class);
+
+ /**
+ * The timeout to establish a connection to the Docling Serve API. Defaults to 5 seconds.
+ *
+ * Providers must honor this option: silently ignoring a timeout surfaces as hangs far from the cause.
+ */
+ public static final ConfigOption CONNECT_TIMEOUT = ConfigOption.of("connectTimeout", Duration.class);
+
+ /**
+ * The timeout for receiving a response from the Docling Serve API. Defaults to 30 seconds.
+ *
+ * Providers must honor this option: silently ignoring a timeout surfaces as hangs far from the cause.
+ */
+ public static final ConfigOption READ_TIMEOUT = ConfigOption.of("readTimeout", Duration.class);
+
+ /**
+ * How frequently the status of an async task is polled, for example by
+ * {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)}. Defaults to 2 seconds.
+ *
+ * Providers may declare this option as unsupported if they do not poll.
+ */
+ public static final ConfigOption ASYNC_POLL_INTERVAL = ConfigOption.of("asyncPollInterval", Duration.class);
+
+ /**
+ * The maximum time to wait for an async task to complete, for example in
+ * {@link DoclingServeApi#convertSourceAsync(ai.docling.serve.api.convert.request.ConvertDocumentRequest)}. Defaults to 5 minutes.
+ *
+ * Providers must honor this option.
+ */
+ public static final ConfigOption ASYNC_TIMEOUT = ConfigOption.of("asyncTimeout", Duration.class);
+
+ /**
+ * The {@link Executor} used to run async operations. Not set by default.
+ *
+ * Providers whose concurrency model makes a caller-supplied executor meaningless (for example a
+ * reactive implementation) may declare this option as unsupported.
+ *
+ * @see #asyncExecutor()
+ */
+ public static final ConfigOption ASYNC_EXECUTOR = ConfigOption.of("asyncExecutor", Executor.class);
+
+ /**
+ * Every option, in declaration order. Package-private: used by the builder, the legacy provider
+ * adapter, and the tests guarding that every option has an accessor and a setter.
+ */
+ static final List> ALL_OPTIONS = List.of(
+ BASE_URL, API_KEY, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR);
+
+ private static final URI DEFAULT_BASE_URL = URI.create("http://localhost:5001");
+ private static final Duration DEFAULT_CONNECT_TIMEOUT = Duration.ofSeconds(5);
+ private static final Duration DEFAULT_READ_TIMEOUT = Duration.ofSeconds(30);
+ private static final Duration DEFAULT_ASYNC_POLL_INTERVAL = Duration.ofSeconds(2);
+ private static final Duration DEFAULT_ASYNC_TIMEOUT = Duration.ofMinutes(5);
+
+ // Only contains explicitly set options, never null values, in declaration order
+ private final Map, Object> values;
+
+ DoclingServeApiConfig(Map, Object> values) {
+ this.values = Collections.unmodifiableMap(
+ ALL_OPTIONS.stream()
+ .filter(values::containsKey)
+ .collect(Collectors.toMap(Function.identity(), values::get, (first, second) -> first, LinkedHashMap::new)));
+ }
+
+ /**
+ * Creates a new builder initialized with the options explicitly set on this configuration.
+ * Options left at their default here are still considered not explicitly set on the returned builder.
+ *
+ * @return a new builder initialized from this configuration
+ */
+ public DoclingServeApiBuilder toBuilder() {
+ return new DoclingServeApiBuilder(this.values);
+ }
+
+ /**
+ * The base URL of the Docling Serve API.
+ *
+ * @return the base URL, or {@code http://localhost:5001} if not set
+ * @see #BASE_URL
+ */
+ public URI baseUrl() {
+ return getOrDefault(BASE_URL, DEFAULT_BASE_URL);
+ }
+
+ /**
+ * The API key used to authenticate requests.
+ *
+ * @return the API key, or {@code null} if not set
+ * @see #API_KEY
+ */
+ public @Nullable String apiKey() {
+ return get(API_KEY);
+ }
+
+ /**
+ * Whether requests are logged.
+ *
+ * @return {@code true} if requests are logged, {@code false} by default
+ * @see #LOG_REQUESTS
+ */
+ public boolean logRequests() {
+ return getOrDefault(LOG_REQUESTS, false);
+ }
+
+ /**
+ * Whether responses are logged.
+ *
+ * @return {@code true} if responses are logged, {@code false} by default
+ * @see #LOG_RESPONSES
+ */
+ public boolean logResponses() {
+ return getOrDefault(LOG_RESPONSES, false);
+ }
+
+ /**
+ * Whether JSON requests and responses are pretty-printed.
+ *
+ * @return {@code true} if JSON is pretty-printed, {@code false} by default
+ * @see #PRETTY_PRINT
+ */
+ public boolean prettyPrint() {
+ return getOrDefault(PRETTY_PRINT, false);
+ }
+
+ /**
+ * The timeout to establish a connection to the Docling Serve API.
+ *
+ * @return the connect timeout, 5 seconds by default
+ * @see #CONNECT_TIMEOUT
+ */
+ public Duration connectTimeout() {
+ return getOrDefault(CONNECT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT);
+ }
+
+ /**
+ * The timeout for receiving a response from the Docling Serve API.
+ *
+ * @return the read timeout, 30 seconds by default
+ * @see #READ_TIMEOUT
+ */
+ public Duration readTimeout() {
+ return getOrDefault(READ_TIMEOUT, DEFAULT_READ_TIMEOUT);
+ }
+
+ /**
+ * How frequently the status of an async task is polled.
+ *
+ * @return the poll interval, 2 seconds by default
+ * @see #ASYNC_POLL_INTERVAL
+ */
+ public Duration asyncPollInterval() {
+ return getOrDefault(ASYNC_POLL_INTERVAL, DEFAULT_ASYNC_POLL_INTERVAL);
+ }
+
+ /**
+ * The maximum time to wait for an async task to complete.
+ *
+ * @return the async timeout, 5 minutes by default
+ * @see #ASYNC_TIMEOUT
+ */
+ public Duration asyncTimeout() {
+ return getOrDefault(ASYNC_TIMEOUT, DEFAULT_ASYNC_TIMEOUT);
+ }
+
+ /**
+ * The {@link Executor} used to run async operations.
+ *
+ * This is deliberately {@code null} rather than defaulted when not set. Implementations must
+ * then fall back to the default async executor of {@link java.util.concurrent.CompletableFuture},
+ * by using the overloads that take no executor. They must not substitute
+ * {@link java.util.concurrent.ForkJoinPool#commonPool()}: it is not equivalent, and
+ * {@link java.util.concurrent.CompletableFuture#delayedExecutor(long, java.util.concurrent.TimeUnit, Executor)}
+ * does not protect against a common pool without workers (for example on a single-CPU container).
+ *
+ *
The lifecycle of the executor is owned by the caller: implementations never shut it down.
+ *
+ * @return the executor, or {@code null} if not set
+ * @see #ASYNC_EXECUTOR
+ */
+ public @Nullable Executor asyncExecutor() {
+ return get(ASYNC_EXECUTOR);
+ }
+
+ /**
+ * Whether the caller explicitly set the given option, as opposed to leaving it at its default value.
+ *
+ * @param option the option to check
+ * @return {@code true} if the option was explicitly set
+ */
+ public boolean isExplicitlySet(ConfigOption> option) {
+ return this.values.containsKey(option);
+ }
+
+ /**
+ * The options explicitly set by the caller.
+ *
+ * @return an unmodifiable set of the explicitly set options, in the declaration order of the {@link ConfigOption} constants
+ */
+ public Set> explicitlySetOptions() {
+ return this.values.keySet();
+ }
+
+ /**
+ * The value of the given option, if it was explicitly set.
+ *
+ * Package-private: providers use the typed accessors instead.
+ */
+ @Nullable T get(ConfigOption option) {
+ return option.type().cast(this.values.get(option));
+ }
+
+ private T getOrDefault(ConfigOption option, T defaultValue) {
+ var value = get(option);
+ return (value != null) ? value : defaultValue;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return (this == o) || ((o instanceof DoclingServeApiConfig other) && this.values.equals(other.values));
+ }
+
+ @Override
+ public int hashCode() {
+ return this.values.hashCode();
+ }
+
+ @Override
+ public String toString() {
+ return "DoclingServeApiConfig%s".formatted(explicitlySetOptions());
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiProviders.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiProviders.java
new file mode 100644
index 00000000..462a3f3c
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiProviders.java
@@ -0,0 +1,76 @@
+package ai.docling.serve.api;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+import ai.docling.serve.api.spi.ServiceLoaderHelper;
+
+/**
+ * Resolves the single {@link DoclingServeApiProvider} to use, falling back to the deprecated
+ * {@link DoclingServeApiBuilderFactory} SPI when no provider is available.
+ */
+@SuppressWarnings("removal")
+final class DoclingServeApiProviders {
+ private static final Logger LOG = LoggerFactory.getLogger(DoclingServeApiProviders.class);
+
+ private DoclingServeApiProviders() {
+ }
+
+ static DoclingServeApiProvider resolve() {
+ var providers = List.copyOf(ServiceLoaderHelper.loadFactories(DoclingServeApiProvider.class));
+ var legacyFactories = List.copyOf(ServiceLoaderHelper.loadFactories(DoclingServeApiBuilderFactory.class));
+
+ return resolve(providers, legacyFactories);
+ }
+
+ // Package-private so the resolution rules can be tested without the ServiceLoader
+ static DoclingServeApiProvider resolve(List providers, List legacyFactories) {
+ if (!providers.isEmpty() && !legacyFactories.isEmpty()) {
+ LOG.warn(
+ """
+ Ignoring deprecated {} implementation(s) [{}] because {} implementation(s) [{}] are available. \
+ {} is deprecated for removal and is no longer used by docling-serve-api: it will be removed in a future release. \
+ Migrate these implementations to {}.""", DoclingServeApiBuilderFactory.class.getName(), describeAll(legacyFactories), DoclingServeApiProvider.class
+ .getName(), describeAll(providers), DoclingServeApiBuilderFactory.class.getSimpleName(), DoclingServeApiProvider.class.getName());
+ }
+
+ return providers.isEmpty() ?
+ exactlyOne(legacyFactories.stream().map(LegacyProviderAdapter::new).toList(), DoclingServeApiBuilderFactory.class) :
+ exactlyOne(providers, DoclingServeApiProvider.class);
+ }
+
+ /**
+ * A human-readable description of a provider, naming the wrapped factory for legacy providers.
+ */
+ static String describe(Object provider) {
+ return (provider instanceof LegacyProviderAdapter legacy) ?
+ legacy.toString() :
+ provider.getClass().getName();
+ }
+
+ private static DoclingServeApiProvider exactlyOne(List candidates, Class> spiType) {
+ return switch (candidates.size()) {
+ case 0 ->
+ throw new IllegalStateException(
+ "No instance of %s (or of the deprecated %s) found to build a %s instance. You are probably missing a library on your classpath."
+ .formatted(DoclingServeApiProvider.class.getName(), DoclingServeApiBuilderFactory.class.getName(), DoclingServeApi.class.getName()));
+ case 1 ->
+ candidates.get(0);
+ default ->
+ throw new IllegalStateException(
+ "Multiple instances of %s found to build a %s instance: [%s]"
+ .formatted(spiType.getName(), DoclingServeApi.class.getName(), describeAll(candidates)));
+ };
+ }
+
+ private static String describeAll(List> providers) {
+ return providers.stream()
+ .map(DoclingServeApiProviders::describe)
+ .collect(Collectors.joining(", "));
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/LegacyProviderAdapter.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/LegacyProviderAdapter.java
new file mode 100644
index 00000000..ba9814e2
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/LegacyProviderAdapter.java
@@ -0,0 +1,65 @@
+package ai.docling.serve.api;
+
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
+import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+
+/**
+ * Adapts a deprecated {@link DoclingServeApiBuilderFactory} to the {@link DoclingServeApiProvider} SPI,
+ * by replaying the explicitly set options onto the builder the factory returns.
+ *
+ * The replay map is frozen: it only covers the setters {@link DoclingApiBuilder} had when the old SPI
+ * was deprecated. Every option added since is therefore declared {@link Unsupported#FAIL}, so a caller
+ * setting it gets an error naming the legacy factory instead of the option being silently dropped.
+ */
+@SuppressWarnings("removal")
+final class LegacyProviderAdapter implements DoclingServeApiProvider {
+ private static final Map, BiConsumer, Object>> REPLAY = Map.ofEntries(
+ replay(DoclingServeApiConfig.BASE_URL, DoclingApiBuilder::baseUrl), replay(DoclingServeApiConfig.API_KEY, DoclingApiBuilder::apiKey), replay(DoclingServeApiConfig.LOG_REQUESTS, DoclingApiBuilder::logRequests), replay(DoclingServeApiConfig.LOG_RESPONSES, DoclingApiBuilder::logResponses), replay(DoclingServeApiConfig.PRETTY_PRINT, DoclingApiBuilder::prettyPrint), replay(DoclingServeApiConfig.CONNECT_TIMEOUT, DoclingApiBuilder::connectTimeout), replay(DoclingServeApiConfig.READ_TIMEOUT, DoclingApiBuilder::readTimeout), replay(DoclingServeApiConfig.ASYNC_POLL_INTERVAL, DoclingApiBuilder::asyncPollInterval), replay(DoclingServeApiConfig.ASYNC_TIMEOUT, DoclingApiBuilder::asyncTimeout));
+
+ private static final Map, Unsupported> UNSUPPORTED = DoclingServeApiConfig.ALL_OPTIONS
+ .stream()
+ .filter(option -> !REPLAY.containsKey(option))
+ .collect(Collectors.toUnmodifiableMap(Function.identity(), option -> Unsupported.FAIL));
+
+ private final DoclingServeApiBuilderFactory factory;
+
+ LegacyProviderAdapter(DoclingServeApiBuilderFactory factory) {
+ this.factory = factory;
+ }
+
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ return create(this.factory, config);
+ }
+
+ @Override
+ public Map, Unsupported> unsupportedOptions() {
+ return UNSUPPORTED;
+ }
+
+ @Override
+ public String toString() {
+ return "%s (deprecated %s)".formatted(this.factory.getClass().getName(), DoclingServeApiBuilderFactory.class.getSimpleName());
+ }
+
+ private static > T create(DoclingServeApiBuilderFactory factory, DoclingServeApiConfig config) {
+ B builder = factory.getBuilder();
+
+ config.explicitlySetOptions()
+ .forEach(option -> Optional.ofNullable(REPLAY.get(option))
+ .ifPresent(setter -> setter.accept(builder, config.get(option))));
+
+ return builder.build();
+ }
+
+ private static Map.Entry, BiConsumer, Object>> replay(ConfigOption option, BiConsumer, T> setter) {
+ return Map.entry(option, (builder, value) -> setter.accept(builder, option.type().cast(value)));
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/UnsupportedConfigurationException.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/UnsupportedConfigurationException.java
new file mode 100644
index 00000000..caa0f2f3
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/UnsupportedConfigurationException.java
@@ -0,0 +1,47 @@
+package ai.docling.serve.api;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Thrown by {@link DoclingServeApiBuilder#build()} when the caller explicitly set options that the
+ * resolved {@link ai.docling.serve.api.spi.DoclingServeApiProvider} declares as {@link ai.docling.serve.api.spi.DoclingServeApiProvider.Unsupported#FAIL}.
+ */
+public class UnsupportedConfigurationException extends IllegalArgumentException {
+ private final String provider;
+ private final List> unsupportedOptions;
+
+ /**
+ * Creates a new exception.
+ *
+ * @param provider a description of the provider that does not support the options
+ * @param unsupportedOptions the explicitly set options the provider does not support
+ */
+ public UnsupportedConfigurationException(String provider, List> unsupportedOptions) {
+ super("The following options were explicitly configured but are not supported by %s: [%s]".formatted(
+ provider, unsupportedOptions.stream()
+ .map(ConfigOption::name)
+ .collect(Collectors.joining(", "))));
+
+ this.provider = provider;
+ this.unsupportedOptions = List.copyOf(unsupportedOptions);
+ }
+
+ /**
+ * A description of the provider that does not support the options.
+ *
+ * @return the provider description
+ */
+ public String getProvider() {
+ return this.provider;
+ }
+
+ /**
+ * The explicitly set options the provider does not support.
+ *
+ * @return an unmodifiable list of the unsupported options
+ */
+ public List> getUnsupportedOptions() {
+ return this.unsupportedOptions;
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiBuilderFactory.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiBuilderFactory.java
index cad1f5e8..74accb56 100644
--- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiBuilderFactory.java
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiBuilderFactory.java
@@ -5,7 +5,14 @@
/**
* Factory interface for creating builder instances to construct implementations of {@link DoclingServeApi}.
+ *
+ * @deprecated Implement {@link DoclingServeApiProvider} instead. This SPI is no longer implemented by any
+ * docling-java module and will be removed in a future release. It is still discovered as a fallback when no
+ * {@link DoclingServeApiProvider} is available, but configuration options added after its deprecation (such as
+ * {@code asyncExecutor}) are rejected when building through it.
*/
+@Deprecated(since = "0.7.0", forRemoval = true)
+@SuppressWarnings("removal")
public interface DoclingServeApiBuilderFactory {
/**
* Retrieves a builder instance for constructing implementations of {@link DoclingServeApi}.
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiProvider.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiProvider.java
new file mode 100644
index 00000000..cf47874d
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiProvider.java
@@ -0,0 +1,75 @@
+package ai.docling.serve.api.spi;
+
+import java.util.Map;
+
+import ai.docling.serve.api.ConfigOption;
+import ai.docling.serve.api.DoclingServeApi;
+import ai.docling.serve.api.DoclingServeApiConfig;
+
+/**
+ * Service provider interface for creating implementations of {@link DoclingServeApi}.
+ *
+ * Implementations are discovered with {@link java.util.ServiceLoader}, either through a
+ * {@code META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider} file or a
+ * {@code provides} clause in {@code module-info.java}. Exactly one provider must be available when
+ * {@link ai.docling.serve.api.DoclingServeApiBuilder#build()} is called.
+ *
+ *
A provider receives an immutable {@link DoclingServeApiConfig} rather than a builder, so new
+ * configuration options can be added in future releases without breaking existing providers.
+ *
+ *
Implementations must be public and have a public no-argument constructor.
+ */
+@FunctionalInterface
+public interface DoclingServeApiProvider {
+ /**
+ * Creates a {@link DoclingServeApi} from the given configuration.
+ *
+ *
By the time this method is called, the options declared in {@link #unsupportedOptions()} have
+ * already been enforced: explicitly set options declared as {@link Unsupported#FAIL} have caused
+ * {@link ai.docling.serve.api.DoclingServeApiBuilder#build()} to fail, and those declared as {@link Unsupported#WARN}
+ * have been logged.
+ *
+ *
The {@link ai.docling.serve.api.DoclingServeApi#config()} of the returned API must report the
+ * effective value of every option of the given configuration.
+ *
+ * @param config the configuration to create the API with
+ * @return a new {@link DoclingServeApi}
+ */
+ DoclingServeApi create(DoclingServeApiConfig config);
+
+ /**
+ * The options this provider knowingly does not honor, and what should happen when a caller
+ * explicitly sets one of them. Options left at their default value are never reported.
+ *
+ *
Options absent from the returned map are assumed to be honored. This is a declaration, not a
+ * verified guarantee: the api module does not check that the options omitted here are actually applied.
+ *
+ *
The default implementation declares no unsupported option.
+ *
+ * @return the unsupported options, never {@code null}
+ */
+ default Map, Unsupported> unsupportedOptions() {
+ return Map.of();
+ }
+
+ /**
+ * What happens when a caller explicitly sets an option a provider does not honor.
+ */
+ enum Unsupported {
+ /**
+ * Silently ignore the option.
+ */
+ IGNORE,
+
+ /**
+ * Log a warning naming the option and the provider, then ignore the option.
+ */
+ WARN,
+
+ /**
+ * Fail {@link ai.docling.serve.api.DoclingServeApiBuilder#build()} with an
+ * {@link ai.docling.serve.api.UnsupportedConfigurationException}.
+ */
+ FAIL
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/module-info.java b/docling-serve/docling-serve-api/src/main/java/module-info.java
index 06678ac3..52d0c8b3 100644
--- a/docling-serve/docling-serve-api/src/main/java/module-info.java
+++ b/docling-serve/docling-serve-api/src/main/java/module-info.java
@@ -1,5 +1,6 @@
open module ai.docling.serve.api {
requires transitive ai.docling.core;
+ requires transitive org.slf4j;
requires static org.jspecify;
requires static lombok;
@@ -45,5 +46,6 @@
// SPI
exports ai.docling.serve.api.spi;
+ uses ai.docling.serve.api.spi.DoclingServeApiProvider;
uses ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiBuilderTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiBuilderTests.java
new file mode 100644
index 00000000..6469abc3
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiBuilderTests.java
@@ -0,0 +1,93 @@
+package ai.docling.serve.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import ai.docling.serve.api.TestApis.RecordingProvider;
+import ai.docling.serve.api.spi.DoclingServeApiProvider.Unsupported;
+
+class DoclingServeApiBuilderTests {
+ @Test
+ void providerReceivesTheConfiguration() {
+ var provider = new RecordingProvider();
+ var builder = DoclingServeApi.builder()
+ .apiKey("key")
+ .readTimeout(Duration.ofSeconds(7));
+
+ var api = builder.build(provider);
+
+ assertThat(provider.created())
+ .singleElement()
+ .isSameAs(api);
+ assertThat(api.config()).isEqualTo(builder.config());
+ }
+
+ @Test
+ void unsupportedOptionsLeftAtTheirDefaultAreNotEnforced() {
+ var provider = new RecordingProvider(Map.of(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.FAIL));
+
+ var api = DoclingServeApi.builder()
+ .apiKey("key")
+ .build(provider);
+
+ assertThat(provider.created())
+ .singleElement()
+ .isSameAs(api);
+ }
+
+ @Test
+ void ignoredOptionsDoNotPreventBuilding() {
+ var provider = new RecordingProvider(Map.of(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.IGNORE));
+
+ var api = DoclingServeApi.builder()
+ .asyncExecutor(Runnable::run)
+ .build(provider);
+
+ assertThat(provider.created())
+ .singleElement()
+ .isSameAs(api);
+ assertThat(api.config().asyncExecutor()).isNotNull();
+ }
+
+ @Test
+ void warnedOptionsDoNotPreventBuilding() {
+ var provider = new RecordingProvider(Map.of(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.WARN));
+
+ var api = DoclingServeApi.builder()
+ .asyncExecutor(Runnable::run)
+ .build(provider);
+
+ assertThat(provider.created())
+ .singleElement()
+ .isSameAs(api);
+ assertThat(api.config().asyncExecutor()).isNotNull();
+ }
+
+ @Test
+ void explicitlySetFailOptionsPreventBuildingAndNameEveryOptionInDeclarationOrder() {
+ var provider = new RecordingProvider(Map.of(
+ DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.FAIL, DoclingServeApiConfig.LOG_REQUESTS, Unsupported.FAIL, DoclingServeApiConfig.PRETTY_PRINT, Unsupported.WARN));
+
+ var builder = DoclingServeApi.builder()
+ .asyncExecutor(Runnable::run)
+ .prettyPrint()
+ .logRequests();
+
+ assertThatExceptionOfType(UnsupportedConfigurationException.class)
+ .isThrownBy(() -> builder.build(provider))
+ .withMessage(
+ "The following options were explicitly configured but are not supported by %s: [logRequests, asyncExecutor]", RecordingProvider.class.getName())
+ .satisfies(e -> assertThat(e)
+ .returns(RecordingProvider.class.getName(), UnsupportedConfigurationException::getProvider)
+ .extracting(UnsupportedConfigurationException::getUnsupportedOptions)
+ .isEqualTo(List.of(DoclingServeApiConfig.LOG_REQUESTS, DoclingServeApiConfig.ASYNC_EXECUTOR)));
+
+ assertThat(provider.created()).isEmpty();
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java
new file mode 100644
index 00000000..84b31672
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java
@@ -0,0 +1,210 @@
+package ai.docling.serve.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.net.URI;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.Executor;
+import java.util.stream.Collectors;
+
+import org.junit.jupiter.api.Test;
+
+class DoclingServeApiConfigTests {
+ private static final Executor EXECUTOR = Runnable::run;
+
+ // Public no-arg methods of DoclingServeApiConfig that are not option accessors
+ private static final Set NON_ACCESSOR_METHODS = Set.of("toBuilder", "explicitlySetOptions", "hashCode", "toString");
+
+ // Public methods of DoclingServeApiBuilder that are not option setters
+ private static final Set NON_SETTER_METHODS = Set.of("config", "build");
+
+ @Test
+ void defaultsWhenNothingIsSet() {
+ var config = DoclingServeApi.builder().config();
+
+ assertThat(config.explicitlySetOptions()).isEmpty();
+ assertThat(config)
+ .returns(URI.create("http://localhost:5001"), DoclingServeApiConfig::baseUrl)
+ .returns(null, DoclingServeApiConfig::apiKey)
+ .returns(false, DoclingServeApiConfig::logRequests)
+ .returns(false, DoclingServeApiConfig::logResponses)
+ .returns(false, DoclingServeApiConfig::prettyPrint)
+ .returns(Duration.ofSeconds(5), DoclingServeApiConfig::connectTimeout)
+ .returns(Duration.ofSeconds(30), DoclingServeApiConfig::readTimeout)
+ .returns(Duration.ofSeconds(2), DoclingServeApiConfig::asyncPollInterval)
+ .returns(Duration.ofMinutes(5), DoclingServeApiConfig::asyncTimeout)
+ .returns(null, DoclingServeApiConfig::asyncExecutor);
+ }
+
+ @Test
+ void setOptionsAreReturnedAndRecordedAsExplicitlySet() {
+ var config = fullyConfigured().config();
+
+ assertThat(config)
+ .returns(URI.create("http://example.com:8080"), DoclingServeApiConfig::baseUrl)
+ .returns("key", DoclingServeApiConfig::apiKey)
+ .returns(true, DoclingServeApiConfig::logRequests)
+ .returns(true, DoclingServeApiConfig::logResponses)
+ .returns(true, DoclingServeApiConfig::prettyPrint)
+ .returns(Duration.ofSeconds(1), DoclingServeApiConfig::connectTimeout)
+ .returns(Duration.ofSeconds(2), DoclingServeApiConfig::readTimeout)
+ .returns(Duration.ofSeconds(3), DoclingServeApiConfig::asyncPollInterval)
+ .returns(Duration.ofSeconds(4), DoclingServeApiConfig::asyncTimeout)
+ .returns(EXECUTOR, DoclingServeApiConfig::asyncExecutor);
+
+ assertThat(config.explicitlySetOptions()).containsExactlyInAnyOrderElementsOf(DoclingServeApiConfig.ALL_OPTIONS);
+ }
+
+ @Test
+ void settingAnOptionToItsDefaultValueStillRecordsItAsExplicitlySet() {
+ var config = DoclingServeApi.builder()
+ .logRequests(false)
+ .config();
+
+ assertThat(config.isExplicitlySet(DoclingServeApiConfig.LOG_REQUESTS)).isTrue();
+ assertThat(config.isExplicitlySet(DoclingServeApiConfig.LOG_RESPONSES)).isFalse();
+ }
+
+ @Test
+ void nullApiKeyUnsetsTheOption() {
+ var config = DoclingServeApi.builder()
+ .apiKey("key")
+ .apiKey(null)
+ .config();
+
+ assertThat(config.apiKey()).isNull();
+ assertThat(config.isExplicitlySet(DoclingServeApiConfig.API_KEY)).isFalse();
+ }
+
+ @Test
+ void configIsASnapshotOfTheBuilder() {
+ var builder = DoclingServeApi.builder().apiKey("key");
+ var config = builder.config();
+
+ builder.apiKey("other");
+
+ assertThat(config.apiKey()).isEqualTo("key");
+ }
+
+ @Test
+ void toBuilderRoundTrips() {
+ var config = fullyConfigured().config();
+
+ assertThat(config.toBuilder().config())
+ .isEqualTo(config)
+ .hasSameHashCodeAs(config);
+ }
+
+ @Test
+ void toBuilderKeepsDefaultedOptionsUnset() {
+ var config = DoclingServeApi.builder()
+ .apiKey("key")
+ .config()
+ .toBuilder()
+ .config();
+
+ assertThat(config.explicitlySetOptions()).containsExactly(DoclingServeApiConfig.API_KEY);
+ }
+
+ @Test
+ void invalidValuesAreRejected() {
+ var builder = DoclingServeApi.builder();
+
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.baseUrl((URI) null)).withMessageContaining("baseUrl");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.baseUrl(" ")).withMessageContaining("baseUrl");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.connectTimeout(Duration.ZERO)).withMessageContaining("connectTimeout");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.readTimeout(Duration.ofSeconds(-1))).withMessageContaining("readTimeout");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.asyncPollInterval(null)).withMessageContaining("asyncPollInterval");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.asyncTimeout(Duration.ZERO)).withMessageContaining("asyncTimeout");
+ assertThatIllegalArgumentException().isThrownBy(() -> builder.asyncExecutor(null)).withMessageContaining("asyncExecutor");
+
+ assertThat(builder.config().explicitlySetOptions()).isEmpty();
+ }
+
+ // The guards below keep the ConfigOption constants, ALL_OPTIONS, the accessors and the setters in sync
+
+ @Test
+ void allOptionsListsEveryConfigOptionConstant() {
+ var constants = Arrays.stream(DoclingServeApiConfig.class.getDeclaredFields())
+ .filter(field -> Modifier.isPublic(field.getModifiers()) && Modifier.isStatic(field.getModifiers()))
+ .filter(field -> field.getType() == ConfigOption.class)
+ .map(DoclingServeApiConfigTests::read)
+ .toList();
+
+ assertThat(DoclingServeApiConfig.ALL_OPTIONS)
+ .doesNotHaveDuplicates()
+ .containsExactlyElementsOf(constants);
+ }
+
+ @Test
+ void everyOptionHasAMatchingAccessorAndViceVersa() {
+ var accessors = Arrays.stream(DoclingServeApiConfig.class.getDeclaredMethods())
+ .filter(method -> Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers()))
+ .filter(method -> method.getParameterCount() == 0)
+ .filter(method -> !NON_ACCESSOR_METHODS.contains(method.getName()))
+ .collect(Collectors.toMap(Method::getName, method -> wrap(method.getReturnType())));
+
+ assertThat(accessors).isEqualTo(optionTypesByName());
+ }
+
+ @Test
+ void everyOptionHasAMatchingSetterAndViceVersa() {
+ var setterNames = Arrays.stream(DoclingServeApiBuilder.class.getDeclaredMethods())
+ .filter(method -> Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers()))
+ .map(Method::getName)
+ .filter(name -> !NON_SETTER_METHODS.contains(name))
+ .collect(Collectors.toSet());
+
+ var optionTypesByName = optionTypesByName();
+
+ assertThat(setterNames).isEqualTo(optionTypesByName.keySet());
+ assertThat(optionTypesByName)
+ .allSatisfy((name, type) -> assertThat(DoclingServeApiBuilder.class.getMethods())
+ .as("setter %s(%s)", name, type.getSimpleName())
+ .anySatisfy(method -> assertThat(method)
+ .returns(name, Method::getName)
+ .returns(1, Method::getParameterCount)
+ .satisfies(m -> assertThat(wrap(m.getParameterTypes()[0])).isEqualTo(type))));
+ }
+
+ private static DoclingServeApiBuilder fullyConfigured() {
+ return DoclingServeApi.builder()
+ .baseUrl("http://example.com:8080")
+ .apiKey("key")
+ .logRequests()
+ .logResponses()
+ .prettyPrint()
+ .connectTimeout(Duration.ofSeconds(1))
+ .readTimeout(Duration.ofSeconds(2))
+ .asyncPollInterval(Duration.ofSeconds(3))
+ .asyncTimeout(Duration.ofSeconds(4))
+ .asyncExecutor(EXECUTOR);
+ }
+
+ private static Map> optionTypesByName() {
+ return DoclingServeApiConfig.ALL_OPTIONS
+ .stream()
+ .collect(Collectors.toMap(ConfigOption::name, option -> option.type()));
+ }
+
+ private static Class> wrap(Class> type) {
+ return MethodType.methodType(type).wrap().returnType();
+ }
+
+ private static ConfigOption> read(Field field) {
+ try {
+ return (ConfigOption>) field.get(null);
+ }
+ catch (IllegalAccessException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiProvidersTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiProvidersTests.java
new file mode 100644
index 00000000..9c2b4435
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiProvidersTests.java
@@ -0,0 +1,72 @@
+package ai.docling.serve.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import ai.docling.serve.api.LegacyProviderAdapterTests.RecordingLegacyFactory;
+import ai.docling.serve.api.TestApis.RecordingProvider;
+import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+
+@SuppressWarnings("removal")
+class DoclingServeApiProvidersTests {
+ @Test
+ void singleProviderIsUsed() {
+ var provider = new RecordingProvider();
+
+ assertThat(DoclingServeApiProviders.resolve(List.of(provider), List.of())).isSameAs(provider);
+ }
+
+ @Test
+ void singleLegacyFactoryIsAdapted() {
+ var factory = new RecordingLegacyFactory();
+
+ assertThat(DoclingServeApiProviders.resolve(List.of(), List.of(factory)))
+ .isInstanceOf(LegacyProviderAdapter.class)
+ .hasToString("%s (deprecated DoclingServeApiBuilderFactory)", RecordingLegacyFactory.class.getName());
+ }
+
+ @Test
+ void providerIsPreferredOverLegacyFactory() {
+ var provider = new RecordingProvider();
+
+ assertThat(DoclingServeApiProviders.resolve(List.of(provider), List.of(new RecordingLegacyFactory()))).isSameAs(provider);
+ }
+
+ @Test
+ void noneFound() {
+ assertThatIllegalStateException()
+ .isThrownBy(() -> DoclingServeApiProviders.resolve(List.of(), List.of()))
+ .withMessage(
+ "No instance of %s (or of the deprecated %s) found to build a %s instance. You are probably missing a library on your classpath.", DoclingServeApiProvider.class
+ .getName(), DoclingServeApiBuilderFactory.class.getName(), DoclingServeApi.class.getName());
+ }
+
+ @Test
+ void multipleProvidersFound() {
+ assertThatIllegalStateException()
+ .isThrownBy(() -> DoclingServeApiProviders.resolve(List.of(new RecordingProvider(), new OtherProvider()), List.of()))
+ .withMessage(
+ "Multiple instances of %s found to build a %s instance: [%s, %s]", DoclingServeApiProvider.class.getName(), DoclingServeApi.class.getName(), RecordingProvider.class
+ .getName(), OtherProvider.class.getName());
+ }
+
+ @Test
+ void multipleLegacyFactoriesFound() {
+ assertThatIllegalStateException()
+ .isThrownBy(() -> DoclingServeApiProviders.resolve(List.of(), List.of(new RecordingLegacyFactory(), new RecordingLegacyFactory())))
+ .withMessageStartingWith("Multiple instances of %s found to build a %s instance: [", DoclingServeApiBuilderFactory.class.getName(), DoclingServeApi.class.getName())
+ .withMessageContaining("%s (deprecated DoclingServeApiBuilderFactory)", RecordingLegacyFactory.class.getName());
+ }
+
+ private static final class OtherProvider implements DoclingServeApiProvider {
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ return new TestApis.StubDoclingServeApi(config);
+ }
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiTests.java
index 5cbee939..905737f7 100644
--- a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiTests.java
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiTests.java
@@ -2,81 +2,19 @@
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import java.net.URI;
-import java.time.Duration;
-
-import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
-import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+@SuppressWarnings("removal")
class DoclingServeApiTests {
@Test
- void noFactoryFound() {
+ void noProviderFound() {
assertThatExceptionOfType(IllegalStateException.class)
- .isThrownBy(() -> DoclingServeApi.builder())
- .withMessage("No instance of %s found to build a %s instance. You are probably missing a library on your classpath.", DoclingServeApiBuilderFactory.class
- .getName(), DoclingApiBuilder.class.getName());
- }
-
- @Test
- void asyncExecutorIsUnsupportedByDefault() {
- assertThatExceptionOfType(UnsupportedOperationException.class)
- .isThrownBy(() -> new MinimalBuilder().asyncExecutor(Runnable::run));
- }
-
- // A builder implementing only the abstract methods of the interface, e.g. one provided through the SPI.
- // This class compiling is what guarantees that new DoclingApiBuilder methods don't break such builders.
- private static final class MinimalBuilder implements DoclingApiBuilder {
- @Override
- public MinimalBuilder baseUrl(URI baseUrl) {
- return this;
- }
-
- @Override
- public MinimalBuilder apiKey(@Nullable String apiKey) {
- return this;
- }
-
- @Override
- public MinimalBuilder logRequests(boolean logRequests) {
- return this;
- }
-
- @Override
- public MinimalBuilder logResponses(boolean logResponses) {
- return this;
- }
-
- @Override
- public MinimalBuilder prettyPrint(boolean prettyPrint) {
- return this;
- }
-
- @Override
- public MinimalBuilder connectTimeout(Duration connectTimeout) {
- return this;
- }
-
- @Override
- public MinimalBuilder readTimeout(Duration readTimeout) {
- return this;
- }
-
- @Override
- public MinimalBuilder asyncPollInterval(Duration asyncPollInterval) {
- return this;
- }
-
- @Override
- public MinimalBuilder asyncTimeout(Duration asyncTimeout) {
- return this;
- }
-
- @Override
- public DoclingServeApi build() {
- throw new UnsupportedOperationException();
- }
+ .isThrownBy(() -> DoclingServeApi.builder().build())
+ .withMessage(
+ "No instance of %s (or of the deprecated %s) found to build a %s instance. You are probably missing a library on your classpath.", DoclingServeApiProvider.class
+ .getName(), DoclingServeApiBuilderFactory.class.getName(), DoclingServeApi.class.getName());
}
}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java
new file mode 100644
index 00000000..67683a15
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java
@@ -0,0 +1,158 @@
+package ai.docling.serve.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Test;
+
+import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
+import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
+import ai.docling.serve.api.spi.DoclingServeApiProvider.Unsupported;
+
+@SuppressWarnings("removal")
+class LegacyProviderAdapterTests {
+ @Test
+ void onlyExplicitlySetOptionsAreReplayed() {
+ var factory = new RecordingLegacyFactory();
+ var builder = DoclingServeApi.builder()
+ .baseUrl("http://example.com")
+ .apiKey("key")
+ .logRequests()
+ .readTimeout(Duration.ofSeconds(7));
+
+ var api = builder.build(new LegacyProviderAdapter(factory));
+
+ assertThat(api.config()).isEqualTo(builder.config());
+ assertThat(factory.builder.calls).containsExactly(
+ Map.entry("baseUrl", URI.create("http://example.com")), Map.entry("apiKey", "key"), Map.entry("logRequests", true), Map.entry("readTimeout", Duration.ofSeconds(7)));
+ }
+
+ @Test
+ void everyLegacyOptionIsReplayed() {
+ var factory = new RecordingLegacyFactory();
+
+ DoclingServeApi.builder()
+ .baseUrl("http://example.com")
+ .apiKey("key")
+ .logRequests()
+ .logResponses()
+ .prettyPrint()
+ .connectTimeout(Duration.ofSeconds(1))
+ .readTimeout(Duration.ofSeconds(2))
+ .asyncPollInterval(Duration.ofSeconds(3))
+ .asyncTimeout(Duration.ofSeconds(4))
+ .build(new LegacyProviderAdapter(factory));
+
+ assertThat(factory.builder.calls.keySet())
+ .containsExactly("baseUrl", "apiKey", "logRequests", "logResponses", "prettyPrint", "connectTimeout", "readTimeout", "asyncPollInterval", "asyncTimeout");
+ }
+
+ @Test
+ void optionsAddedAfterTheDeprecationAreUnsupported() {
+ assertThat(new LegacyProviderAdapter(new RecordingLegacyFactory()).unsupportedOptions())
+ .containsExactly(Map.entry(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.FAIL));
+ }
+
+ @Test
+ void settingAnOptionAddedAfterTheDeprecationFailsNamingTheLegacyFactory() {
+ var factory = new RecordingLegacyFactory();
+ var builder = DoclingServeApi.builder().asyncExecutor(Runnable::run);
+
+ assertThatExceptionOfType(UnsupportedConfigurationException.class)
+ .isThrownBy(() -> builder.build(new LegacyProviderAdapter(factory)))
+ .withMessage(
+ "The following options were explicitly configured but are not supported by %s (deprecated DoclingServeApiBuilderFactory): [asyncExecutor]", RecordingLegacyFactory.class
+ .getName());
+
+ assertThat(factory.builder.calls).isEmpty();
+ }
+
+ static final class RecordingLegacyFactory implements DoclingServeApiBuilderFactory {
+ final RecordingLegacyBuilder builder = new RecordingLegacyBuilder();
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public > B getBuilder() {
+ return (B) this.builder;
+ }
+ }
+
+ // Implements only the abstract methods of DoclingApiBuilder, like a third-party builder would:
+ // this class compiling is what guarantees the deprecated interface did not gain abstract methods.
+ // It builds a stub reporting the settings it received, as a legacy implementation recompiled
+ // against this version would.
+ static final class RecordingLegacyBuilder implements DoclingApiBuilder {
+ final Map calls = new LinkedHashMap<>();
+ private final DoclingServeApiBuilder settings = DoclingServeApi.builder();
+
+ @Override
+ public RecordingLegacyBuilder baseUrl(URI baseUrl) {
+ this.settings.baseUrl(baseUrl);
+ return record("baseUrl", baseUrl);
+ }
+
+ @Override
+ public RecordingLegacyBuilder apiKey(@Nullable String apiKey) {
+ this.settings.apiKey(apiKey);
+ return record("apiKey", apiKey);
+ }
+
+ @Override
+ public RecordingLegacyBuilder logRequests(boolean logRequests) {
+ this.settings.logRequests(logRequests);
+ return record("logRequests", logRequests);
+ }
+
+ @Override
+ public RecordingLegacyBuilder logResponses(boolean logResponses) {
+ this.settings.logResponses(logResponses);
+ return record("logResponses", logResponses);
+ }
+
+ @Override
+ public RecordingLegacyBuilder prettyPrint(boolean prettyPrint) {
+ this.settings.prettyPrint(prettyPrint);
+ return record("prettyPrint", prettyPrint);
+ }
+
+ @Override
+ public RecordingLegacyBuilder connectTimeout(Duration connectTimeout) {
+ this.settings.connectTimeout(connectTimeout);
+ return record("connectTimeout", connectTimeout);
+ }
+
+ @Override
+ public RecordingLegacyBuilder readTimeout(Duration readTimeout) {
+ this.settings.readTimeout(readTimeout);
+ return record("readTimeout", readTimeout);
+ }
+
+ @Override
+ public RecordingLegacyBuilder asyncPollInterval(Duration asyncPollInterval) {
+ this.settings.asyncPollInterval(asyncPollInterval);
+ return record("asyncPollInterval", asyncPollInterval);
+ }
+
+ @Override
+ public RecordingLegacyBuilder asyncTimeout(Duration asyncTimeout) {
+ this.settings.asyncTimeout(asyncTimeout);
+ return record("asyncTimeout", asyncTimeout);
+ }
+
+ @Override
+ public DoclingServeApi build() {
+ return new TestApis.StubDoclingServeApi(this.settings.config());
+ }
+
+ private RecordingLegacyBuilder record(String name, @Nullable Object value) {
+ this.calls.put(name, value);
+ return this;
+ }
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/TestApis.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/TestApis.java
new file mode 100644
index 00000000..b0aac3a3
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/TestApis.java
@@ -0,0 +1,164 @@
+package ai.docling.serve.api;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletionStage;
+
+import ai.docling.serve.api.chunk.request.HierarchicalChunkDocumentRequest;
+import ai.docling.serve.api.chunk.request.HybridChunkDocumentRequest;
+import ai.docling.serve.api.chunk.response.ChunkDocumentResponse;
+import ai.docling.serve.api.clear.request.ClearConvertersRequest;
+import ai.docling.serve.api.clear.request.ClearResultsRequest;
+import ai.docling.serve.api.clear.response.ClearResponse;
+import ai.docling.serve.api.convert.request.BatchConvertDocumentRequest;
+import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
+import ai.docling.serve.api.convert.response.ConvertDocumentResponse;
+import ai.docling.serve.api.health.HealthCheckResponse;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+import ai.docling.serve.api.task.request.TaskResultRequest;
+import ai.docling.serve.api.task.request.TaskStatusPollRequest;
+import ai.docling.serve.api.task.response.TaskStatusPollResponse;
+
+/**
+ * Test doubles shared by the api module tests.
+ */
+final class TestApis {
+ private TestApis() {
+ }
+
+ /**
+ * A {@link DoclingServeApi} created from a configuration. It reports that configuration from
+ * {@link #config()}, as the provider contract requires, and throws from every operation: the tests
+ * only care about its identity and its configuration.
+ */
+ static final class StubDoclingServeApi implements DoclingServeApi {
+ private final DoclingServeApiConfig config;
+
+ StubDoclingServeApi(DoclingServeApiConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public DoclingServeApiConfig config() {
+ return this.config;
+ }
+
+ @Override
+ @SuppressWarnings("removal")
+ public > DoclingApiBuilder toBuilder() {
+ throw notStubbed("toBuilder");
+ }
+
+ @Override
+ public HealthCheckResponse health() {
+ throw notStubbed("health");
+ }
+
+ @Override
+ public ConvertDocumentResponse convertSource(ConvertDocumentRequest request) {
+ throw notStubbed("convertSource");
+ }
+
+ @Override
+ public CompletionStage convertSourceAsync(ConvertDocumentRequest request) {
+ throw notStubbed("convertSourceAsync");
+ }
+
+ @Override
+ public TaskStatusPollResponse convertSourceBatch(BatchConvertDocumentRequest request) {
+ throw notStubbed("convertSourceBatch");
+ }
+
+ @Override
+ public CompletionStage convertSourceBatchAsync(BatchConvertDocumentRequest request) {
+ throw notStubbed("convertSourceBatchAsync");
+ }
+
+ @Override
+ public ChunkDocumentResponse chunkSourceWithHierarchicalChunker(HierarchicalChunkDocumentRequest request) {
+ throw notStubbed("chunkSourceWithHierarchicalChunker");
+ }
+
+ @Override
+ public ChunkDocumentResponse chunkSourceWithHybridChunker(HybridChunkDocumentRequest request) {
+ throw notStubbed("chunkSourceWithHybridChunker");
+ }
+
+ @Override
+ public CompletionStage chunkSourceWithHierarchicalChunkerAsync(HierarchicalChunkDocumentRequest request) {
+ throw notStubbed("chunkSourceWithHierarchicalChunkerAsync");
+ }
+
+ @Override
+ public CompletionStage chunkSourceWithHybridChunkerAsync(HybridChunkDocumentRequest request) {
+ throw notStubbed("chunkSourceWithHybridChunkerAsync");
+ }
+
+ @Override
+ public ClearResponse clearConverters(ClearConvertersRequest request) {
+ throw notStubbed("clearConverters");
+ }
+
+ @Override
+ public ClearResponse clearResults(ClearResultsRequest request) {
+ throw notStubbed("clearResults");
+ }
+
+ @Override
+ public TaskStatusPollResponse pollTaskStatus(TaskStatusPollRequest request) {
+ throw notStubbed("pollTaskStatus");
+ }
+
+ @Override
+ public ConvertDocumentResponse convertTaskResult(TaskResultRequest request) {
+ throw notStubbed("convertTaskResult");
+ }
+
+ @Override
+ public ChunkDocumentResponse chunkTaskResult(TaskResultRequest request) {
+ throw notStubbed("chunkTaskResult");
+ }
+
+ @Override
+ public String toString() {
+ return "StubDoclingServeApi[config=%s]".formatted(this.config);
+ }
+
+ private static UnsupportedOperationException notStubbed(String method) {
+ return new UnsupportedOperationException("%s is not stubbed".formatted(method));
+ }
+ }
+
+ /**
+ * A provider creating a {@link StubDoclingServeApi}, and recording every API it creates.
+ */
+ static final class RecordingProvider implements DoclingServeApiProvider {
+ private final Map, Unsupported> unsupportedOptions;
+ private final List created = new ArrayList<>();
+
+ RecordingProvider() {
+ this(Map.of());
+ }
+
+ RecordingProvider(Map, Unsupported> unsupportedOptions) {
+ this.unsupportedOptions = unsupportedOptions;
+ }
+
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ var api = new StubDoclingServeApi(config);
+ this.created.add(api);
+ return api;
+ }
+
+ @Override
+ public Map, Unsupported> unsupportedOptions() {
+ return this.unsupportedOptions;
+ }
+
+ List created() {
+ return List.copyOf(this.created);
+ }
+ }
+}
diff --git a/docling-serve/docling-serve-client/build.gradle.kts b/docling-serve/docling-serve-client/build.gradle.kts
index 9731493a..ad03ae16 100644
--- a/docling-serve/docling-serve-client/build.gradle.kts
+++ b/docling-serve/docling-serve-client/build.gradle.kts
@@ -7,7 +7,7 @@ description = "Docling Serve Client"
dependencies {
api(project(":docling-serve-api"))
- api(libs.slf4j.api)
+ implementation(libs.slf4j.api)
compileOnly(platform(libs.jackson.bom))
compileOnly(libs.jackson.databind)
compileOnly(libs.jackson2.databind)
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java
index c8097709..faffaa2a 100644
--- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java
@@ -29,11 +29,8 @@
import org.slf4j.LoggerFactory;
import ai.docling.serve.api.DoclingServeApi;
-import ai.docling.serve.api.DoclingServeChunkApi;
-import ai.docling.serve.api.DoclingServeClearApi;
-import ai.docling.serve.api.DoclingServeConvertApi;
-import ai.docling.serve.api.DoclingServeHealthApi;
-import ai.docling.serve.api.DoclingServeTaskApi;
+import ai.docling.serve.api.DoclingServeApiBuilder;
+import ai.docling.serve.api.DoclingServeApiConfig;
import ai.docling.serve.api.chunk.request.HierarchicalChunkDocumentRequest;
import ai.docling.serve.api.chunk.request.HybridChunkDocumentRequest;
import ai.docling.serve.api.chunk.response.ChunkDocumentResponse;
@@ -48,7 +45,6 @@
import ai.docling.serve.api.task.request.TaskStatusPollRequest;
import ai.docling.serve.api.task.response.TaskStatusPollResponse;
import ai.docling.serve.api.util.Utils;
-import ai.docling.serve.api.util.ValidationUtils;
import ai.docling.serve.api.validation.ValidationError;
import ai.docling.serve.api.validation.ValidationErrorDetail;
import ai.docling.serve.api.validation.ValidationException;
@@ -70,8 +66,8 @@
* deserialization, allowing implementation-specific customization.
*
* The client is structured hierarchically, with separate nested implementations
- * for each API interface ({@link DoclingServeHealthApi}, {@link DoclingServeConvertApi},
- * {@link DoclingServeChunkApi}, {@link DoclingServeClearApi}, {@link DoclingServeTaskApi}).
+ * for each API interface ({@link ai.docling.serve.api.DoclingServeHealthApi}, {@link ai.docling.serve.api.DoclingServeConvertApi},
+ * {@link ai.docling.serve.api.DoclingServeChunkApi}, {@link ai.docling.serve.api.DoclingServeClearApi}, {@link ai.docling.serve.api.DoclingServeTaskApi}).
* These implementations share common HTTP execution logic and configuration.
*
*
Concrete subclasses must implement {@link #readValue(String, Class)} and
@@ -83,15 +79,7 @@ public abstract class DoclingServeClient extends HttpOperations implements Docli
private final URI baseUrl;
private final HttpClient httpClient;
- private final boolean logRequests;
- private final boolean logResponses;
- private final boolean prettyPrintJson;
- private final @Nullable String apiKey;
- private final Duration connectTimeout;
- private final Duration readTimeout;
- private final Duration asyncPollInterval;
- private final Duration asyncTimeout;
- private final @Nullable Executor asyncExecutor;
+ private final DoclingServeApiConfig config;
private final HealthOperations healthOps;
private final ConvertOperations convertOps;
@@ -99,13 +87,48 @@ public abstract class DoclingServeClient extends HttpOperations implements Docli
private final ClearOperations clearOps;
private final TaskOperations taskOps;
+ /**
+ * Creates a builder for the client matching the version of Jackson on the classpath: a
+ * {@link DoclingServeJackson3Client} if Jackson 3 is available, otherwise a {@link DoclingServeJackson2Client}.
+ *
+ *
Use this builder for client-specific settings that don't depend on Jackson, such as
+ * {@link DoclingServeClientBuilder#httpClientBuilder(HttpClient.Builder)}. For the options shared by every
+ * implementation, prefer {@link DoclingServeApi#builder()}. To customize the JSON mapper, which depends on the
+ * version of Jackson, use {@link DoclingServeJackson3Client#builder()} or {@link DoclingServeJackson2Client#builder()}.
+ *
+ * @return a new builder for the detected client
+ * @throws IllegalStateException if neither Jackson 2 nor Jackson 3 is on the classpath
+ */
+ public static DoclingServeClientBuilder, ?> builder() {
+ return builderFor(Thread.currentThread().getContextClassLoader());
+ }
+
+ // Package-private so the detection can be tested with a class loader hiding Jackson
+ static DoclingServeClientBuilder, ?> builderFor(ClassLoader classLoader) {
+ if (JacksonVersion.JACKSON_3.isOnClasspath(classLoader)) {
+ return DoclingServeJackson3Client.builder();
+ }
+ else if (JacksonVersion.JACKSON_2.isOnClasspath(classLoader)) {
+ return DoclingServeJackson2Client.builder();
+ }
+
+ throw new IllegalStateException("""
+ Neither Jackson 2 nor Jackson 3 is on the classpath. You must add one of the following dependencies:
+
+ For Jackson 2:
+ Maven: com.fasterxml.jackson.core:jackson-databind
+ Gradle: implementation("com.fasterxml.jackson.core:jackson-databind:")
+
+ For Jackson 3:
+ Maven: tools.jackson.core:jackson-databind
+ Gradle: implementation("tools.jackson.core:jackson-databind:")
+ """);
+ }
+
protected DoclingServeClient(DoclingServeClientBuilder builder) {
- ValidationUtils.ensurePositiveDuration(builder.connectTimeout, "connectTimeout");
- ValidationUtils.ensurePositiveDuration(builder.readTimeout, "readTimeout");
- ValidationUtils.ensurePositiveDuration(builder.asyncPollInterval, "asyncPollInterval");
- ValidationUtils.ensurePositiveDuration(builder.asyncTimeout, "asyncTimeout");
+ this.config = builder.settings.config();
- var base = ensureNotNull(builder.baseUrl, "baseUrl");
+ var base = this.config.baseUrl();
if (Objects.equals(base.getScheme(), "http")) {
// Docling Serve uses Python FastAPI which causes errors when called from JDK HttpClient.
@@ -118,29 +141,32 @@ protected DoclingServeClient(DoclingServeClientBuilder builder) {
URI.create(base + "/") :
base;
- this.connectTimeout = builder.connectTimeout;
- this.readTimeout = builder.readTimeout;
-
- this.httpClient = ensureNotNull(builder.httpClientBuilder, "httpClientBuilder")
- .connectTimeout(connectTimeout)
+ this.httpClient = builder.httpClientBuilder
+ .connectTimeout(this.config.connectTimeout())
.build();
- this.logRequests = builder.logRequests;
- this.logResponses = builder.logResponses;
- this.prettyPrintJson = builder.prettyPrintJson;
- this.apiKey = builder.apiKey;
- this.asyncPollInterval = builder.asyncPollInterval;
- this.asyncTimeout = builder.asyncTimeout;
- this.asyncExecutor = builder.asyncExecutor;
-
// Initialize operations handlers
this.healthOps = new HealthOperations(this);
this.taskOps = new TaskOperations(this);
- this.convertOps = new ConvertOperations(this, this.taskOps, this.asyncPollInterval, this.asyncTimeout, this.asyncExecutor);
- this.chunkOps = new ChunkOperations(this, this.taskOps, this.asyncPollInterval, this.asyncTimeout, this.asyncExecutor);
+ this.convertOps = new ConvertOperations(this, this.taskOps, this.config.asyncPollInterval(), this.config.asyncTimeout(), this.config.asyncExecutor());
+ this.chunkOps = new ChunkOperations(this, this.taskOps, this.config.asyncPollInterval(), this.config.asyncTimeout(), this.config.asyncExecutor());
this.clearOps = new ClearOperations(this);
}
+ /**
+ * {@inheritDoc}
+ *
+ * This is the configuration the client was built with, as set on its builder: options left at their
+ * default value are not recorded as explicitly set.
+ *
+ *
Client-specific settings, such as the HTTP client or the JSON mapper, are not part of the
+ * configuration: use the {@code toBuilder()} method of the concrete client to keep them.
+ */
+ @Override
+ public DoclingServeApiConfig config() {
+ return this.config;
+ }
+
/**
* Reads and deserializes the given JSON string into an instance of the specified type.
*
@@ -163,7 +189,7 @@ protected DoclingServeClient(DoclingServeClientBuilder builder) {
protected abstract String writeValueAsString(T value);
protected boolean prettyPrintJson() {
- return this.prettyPrintJson;
+ return this.config.prettyPrint();
}
protected void logRequest(HttpRequest request) {
@@ -207,14 +233,14 @@ protected void logResponse(HttpResponse response, Optional respo
);
responseBody
- .map(body -> this.prettyPrintJson ? writeValueAsString(readValue(body, Object.class)) : body)
+ .map(body -> this.config.prettyPrint() ? writeValueAsString(readValue(body, Object.class)) : body)
.ifPresent(body -> stringBuilder.append(" BODY:\n%s".formatted(body)));
LOG.info(stringBuilder.toString());
}
}
protected T execute(HttpRequest request, Class expectedValueType) {
- if (this.logRequests) {
+ if (this.config.logRequests()) {
logRequest(request);
}
@@ -281,10 +307,12 @@ protected HttpRequest.Builder createRequestBuilder(RequestContext r
var requestBuilder = HttpRequest.newBuilder()
.uri(this.baseUrl.resolve(resolvePath(requestContext.getUri())))
.header("Accept", "application/json")
- .timeout(this.readTimeout);
+ .timeout(this.config.readTimeout());
+
+ var apiKey = this.config.apiKey();
- if (Utils.isNotNullOrBlank(this.apiKey)) {
- requestBuilder.header(API_KEY_HEADER_NAME, this.apiKey);
+ if (Utils.isNotNullOrBlank(apiKey)) {
+ requestBuilder.header(API_KEY_HEADER_NAME, apiKey);
}
return requestBuilder;
@@ -301,7 +329,7 @@ protected T getResponse(HttpRequest request, HttpResponse> response, Class
var body = response.body();
// if expectedReturnType is StreamResponse.class, avoid logging potential binary data
- if (this.logResponses && !(StreamResponse.class.equals(expectedReturnType))) {
+ if (this.config.logResponses() && !(StreamResponse.class.equals(expectedReturnType))) {
logResponse((HttpResponse) response, Optional.ofNullable(body.toString()));
}
@@ -440,7 +468,7 @@ public long contentLength() {
@Override
public void subscribe(Subscriber super ByteBuffer> subscriber) {
- if (logRequests) {
+ if (config.logRequests()) {
LOG.info("\n→ REQUEST BODY: \n{}", this.stringContent);
}
@@ -451,149 +479,196 @@ public void subscribe(Subscriber super ByteBuffer> subscriber) {
/**
* Abstract base class for building instances of {@link DoclingServeClient}.
*
- * This builder class provides methods for configuring shared properties such as the
- * base URL and the HTTP client. Concrete subclasses may extend this builder to
- * add additional configuration options.
+ *
The options shared by every implementation are collected into a {@link DoclingServeApiConfig}, which the
+ * built client reports from {@link DoclingServeClient#config()}. This builder adds the client-specific settings,
+ * such as the {@link HttpClient}, and concrete subclasses add their own, such as the JSON mapper.
+ *
+ *
Values are validated when they are set.
*
* @param the type of {@link DoclingServeClient} being built
* @param the type of the builder implementation
*/
- @SuppressWarnings("unchecked")
+ @SuppressWarnings({
+ "unchecked",
+ "removal"
+ })
public abstract static class DoclingServeClientBuilder> implements DoclingApiBuilder {
- private URI baseUrl = DEFAULT_BASE_URL;
- private HttpClient.Builder httpClientBuilder = HttpClient.newBuilder().followRedirects(Redirect.NORMAL);
- private boolean logRequests = false;
- private boolean logResponses = false;
- private boolean prettyPrintJson = false;
- private @Nullable String apiKey;
- private Duration connectTimeout = Duration.ofSeconds(5);
- private Duration readTimeout = Duration.ofSeconds(30);
- private Duration asyncPollInterval = Duration.ofSeconds(2);
- private Duration asyncTimeout = Duration.ofMinutes(5);
- private @Nullable Executor asyncExecutor;
+ private DoclingServeApiBuilder settings;
+ private HttpClient.Builder httpClientBuilder;
/**
* Protected constructor for use by subclasses of {@link DoclingServeClientBuilder}.
*
* Initializes a new instance of the builder with default configuration values.
- * This constructor ensures that the builder cannot be instantiated directly,
- * promoting proper use and extension by subclasses.
*/
protected DoclingServeClientBuilder() {
+ this.settings = DoclingServeApi.builder();
+ this.httpClientBuilder = HttpClient.newBuilder().followRedirects(Redirect.NORMAL);
}
/**
* Initializes a new {@link DoclingServeClientBuilder} instance using the configuration
- * from the provided {@link DoclingServeClient}.
+ * of the provided {@link DoclingServeClient}.
+ *
+ *
Settings of the underlying {@link HttpClient} are not kept, apart from its redirect policy:
+ * a proxy, an SSL context or an authenticator must be configured again with
+ * {@link #httpClientBuilder(HttpClient.Builder)}.
*
- * @param doclingClient the {@link DoclingServeClient} whose configuration (e.g., base URL)
- * will be used to initialize the builder
+ * @param doclingClient the {@link DoclingServeClient} whose configuration will be used to initialize the builder
*/
protected DoclingServeClientBuilder(DoclingServeClient doclingClient) {
- this.baseUrl = doclingClient.baseUrl;
- this.httpClientBuilder = HttpClient.newBuilder();
- this.apiKey = doclingClient.apiKey;
- this.logRequests = doclingClient.logRequests;
- this.logResponses = doclingClient.logResponses;
- this.prettyPrintJson = doclingClient.prettyPrintJson;
- this.asyncPollInterval = doclingClient.asyncPollInterval;
- this.asyncTimeout = doclingClient.asyncTimeout;
- this.asyncExecutor = doclingClient.asyncExecutor;
+ this.settings = doclingClient.config.toBuilder();
+ this.httpClientBuilder = HttpClient.newBuilder()
+ .followRedirects(doclingClient.httpClient.followRedirects());
}
/**
- * Sets the base URL for the client.
+ * Replaces every option shared by all implementations with the given configuration. Options that
+ * aren't explicitly set in it fall back to their default value. Client-specific settings are kept.
*
- *
This method configures the base URL that will be used for all API requests
- * executed by the client. The provided URL must be non-null.
+ * @param config the configuration to apply
+ * @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code config} is null
+ */
+ public B config(DoclingServeApiConfig config) {
+ this.settings = ensureNotNull(config, "config").toBuilder();
+ return (B) this;
+ }
+
+ /**
+ * Sets the base URL for the client.
*
* @param baseUrl the base URL to use, as a {@link URI}
* @return this builder instance for method chaining
* @throws IllegalArgumentException if {@code baseUrl} is null
+ * @see DoclingServeApiConfig#BASE_URL
*/
@Override
public B baseUrl(URI baseUrl) {
- this.baseUrl = baseUrl;
+ this.settings.baseUrl(baseUrl);
return (B) this;
}
/**
* Sets the HTTP client builder to be used for creating the underlying HTTP client.
*
- *
This allows customization of HTTP client properties such as timeouts,
- * proxy settings, SSL context, and other connection parameters.
+ *
This allows customization of HTTP client properties such as proxy settings, SSL context,
+ * and other connection parameters. The connect timeout is always set from {@link #connectTimeout(Duration)}.
*
* @param httpClientBuilder the {@link HttpClient.Builder} to use
- * @return this {@link DoclingServeJackson3Client.Builder} instance for method chaining
+ * @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code httpClientBuilder} is null
*/
public B httpClientBuilder(HttpClient.Builder httpClientBuilder) {
- this.httpClientBuilder = httpClientBuilder;
+ this.httpClientBuilder = ensureNotNull(httpClientBuilder, "httpClientBuilder");
return (B) this;
}
+ /**
+ * Sets the API key used to authenticate requests.
+ *
+ * @param apiKey the API key, or {@code null} to unset it
+ * @return this builder instance for method chaining
+ * @see DoclingServeApiConfig#API_KEY
+ */
@Override
public B apiKey(@Nullable String apiKey) {
- this.apiKey = apiKey;
+ this.settings.apiKey(apiKey);
return (B) this;
}
+ /**
+ * Sets whether requests are logged.
+ *
+ * @param logRequests {@code true} to log requests
+ * @return this builder instance for method chaining
+ * @see DoclingServeApiConfig#LOG_REQUESTS
+ */
@Override
public B logRequests(boolean logRequests) {
- this.logRequests = logRequests;
+ this.settings.logRequests(logRequests);
return (B) this;
}
+ /**
+ * Sets whether responses are logged.
+ *
+ * @param logResponses {@code true} to log responses
+ * @return this builder instance for method chaining
+ * @see DoclingServeApiConfig#LOG_RESPONSES
+ */
@Override
public B logResponses(boolean logResponses) {
- this.logResponses = logResponses;
+ this.settings.logResponses(logResponses);
return (B) this;
}
+ /**
+ * Sets whether JSON requests and responses are pretty-printed.
+ *
+ * @param prettyPrint {@code true} to pretty-print JSON
+ * @return this builder instance for method chaining
+ * @see DoclingServeApiConfig#PRETTY_PRINT
+ */
@Override
public B prettyPrint(boolean prettyPrint) {
- this.prettyPrintJson = prettyPrint;
+ this.settings.prettyPrint(prettyPrint);
return (B) this;
}
+ /**
+ * Sets the timeout to establish a connection to the Docling Serve API.
+ *
+ * @param connectTimeout the connect timeout
+ * @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code connectTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#CONNECT_TIMEOUT
+ */
@Override
public B connectTimeout(Duration connectTimeout) {
- this.connectTimeout = connectTimeout;
+ this.settings.connectTimeout(connectTimeout);
return (B) this;
}
+ /**
+ * Sets the timeout for receiving a response from the Docling Serve API.
+ *
+ * @param readTimeout the read timeout
+ * @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code readTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#READ_TIMEOUT
+ */
@Override
public B readTimeout(Duration readTimeout) {
- this.readTimeout = readTimeout;
+ this.settings.readTimeout(readTimeout);
return (B) this;
}
/**
- * Sets the polling interval for async operations.
+ * Sets how frequently the status of an async task is polled.
*
- *
This configures how frequently the client will check the status of async
- * conversion tasks when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest).
- *
- * @param asyncPollInterval the polling interval (must not be null or negative)
+ * @param asyncPollInterval the poll interval
* @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code asyncPollInterval} is null, zero or negative
+ * @see DoclingServeApiConfig#ASYNC_POLL_INTERVAL
*/
@Override
public B asyncPollInterval(Duration asyncPollInterval) {
- this.asyncPollInterval = asyncPollInterval;
+ this.settings.asyncPollInterval(asyncPollInterval);
return (B) this;
}
/**
- * Sets the timeout for async operations.
- *
- *
This configures the maximum time to wait for an async conversion task to complete
- * when using {@link DoclingServeApi#convertSourceAsync(ConvertDocumentRequest)} (ConvertDocumentRequest).
+ * Sets the maximum time to wait for an async task to complete.
*
- * @param asyncTimeout the timeout duration (must not be null or negative)
+ * @param asyncTimeout the async timeout
* @return this builder instance for method chaining
+ * @throws IllegalArgumentException if {@code asyncTimeout} is null, zero or negative
+ * @see DoclingServeApiConfig#ASYNC_TIMEOUT
*/
@Override
public B asyncTimeout(Duration asyncTimeout) {
- this.asyncTimeout = asyncTimeout;
+ this.settings.asyncTimeout(asyncTimeout);
return (B) this;
}
@@ -603,14 +678,35 @@ public B asyncTimeout(Duration asyncTimeout) {
*
If not set, async operations run on the default async executor of
* {@link java.util.concurrent.CompletableFuture}. The executor is never shut down by the client.
*
- * @param asyncExecutor the executor to use for async operations (must not be null)
+ * @param asyncExecutor the executor to use for async operations
* @return this builder instance for method chaining
- * @throws IllegalArgumentException if asyncExecutor is null
+ * @throws IllegalArgumentException if {@code asyncExecutor} is null
+ * @see DoclingServeApiConfig#ASYNC_EXECUTOR
*/
- @Override
public B asyncExecutor(Executor asyncExecutor) {
- this.asyncExecutor = ensureNotNull(asyncExecutor, "asyncExecutor");
+ this.settings.asyncExecutor(asyncExecutor);
return (B) this;
}
}
+
+ private enum JacksonVersion {
+ JACKSON_2("com.fasterxml.jackson.databind.json.JsonMapper"),
+ JACKSON_3("tools.jackson.databind.json.JsonMapper");
+
+ private final String jacksonClassName;
+
+ JacksonVersion(String jacksonClassName) {
+ this.jacksonClassName = jacksonClassName;
+ }
+
+ private boolean isOnClasspath(ClassLoader classLoader) {
+ try {
+ Class.forName(this.jacksonClassName, false, classLoader);
+ return true;
+ }
+ catch (ClassNotFoundException e) {
+ return false;
+ }
+ }
+ }
}
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java
index 0d76368f..f37159b6 100644
--- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java
@@ -1,24 +1,22 @@
package ai.docling.serve.client;
-import ai.docling.serve.api.DoclingServeApi;
-import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
-import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
import ai.docling.serve.client.DoclingServeClient.DoclingServeClientBuilder;
/**
- * A factory class for creating instances of {@link DoclingServeClientBuilder}.
+ * A factory class for creating instances of {@link DoclingServeClientBuilder}, matching the version of Jackson
+ * (2 or 3) on the classpath.
*
- *
This factory determines which version of Jackson (2 or 3) is available
- * on the application's classpath and provides a corresponding builder for
- * creating {@link DoclingServeClient} implementations.
- *
- *
If neither Jackson 2 nor Jackson 3 is present on the classpath, an
- * {@link IllegalStateException} is thrown.
- *
- *
The factory uses a type-safe generic method to support custom subclasses of
- * {@link DoclingServeClient} and {@link DoclingServeClientBuilder}.
+ * @deprecated Use {@link ai.docling.serve.api.DoclingServeApi#builder()} for the options shared by every
+ * implementation, or {@link DoclingServeClient#builder()} for client-specific settings such as the
+ * HTTP client: it detects the version of Jackson the same way. To customize the JSON mapper, use
+ * {@link DoclingServeJackson3Client#builder()} or {@link DoclingServeJackson2Client#builder()}. This
+ * class will be removed in a future release.
*/
-public final class DoclingServeClientBuilderFactory implements DoclingServeApiBuilderFactory {
+@Deprecated(since = "0.7.0", forRemoval = true)
+public final class DoclingServeClientBuilderFactory {
+ private DoclingServeClientBuilderFactory() {
+ }
+
/**
* Creates and returns a new instance of a {@link DoclingServeClientBuilder} compatible
* with the Jackson version present on the provided classloader's classpath.
@@ -28,35 +26,17 @@ public final class DoclingServeClientBuilderFactory implements DoclingServeApiBu
* If neither version of Jackson is found on the classpath, an
* {@link IllegalStateException} is thrown.
*
- *
The method uses generics to support custom implementations of
- * {@link DoclingServeClient} and {@link DoclingServeClientBuilder}.
- *
- * @param the type of {@link DoclingServeClient} to be created by the builder
- * @param the type of {@link DoclingServeClientBuilder} to be returned
+ * @param the type of {@link DoclingServeClient} to be created by the builder
+ * @param the type of {@link DoclingServeClientBuilder} to be returned
* @param classLoader the {@link ClassLoader} used to check for Jackson's presence
* @return a compatible {@link DoclingServeClientBuilder} instance
* @throws IllegalStateException if neither Jackson 2 nor Jackson 3 is available on the classpath
+ * @deprecated Use {@link DoclingServeClient#builder()} instead.
*/
+ @Deprecated(since = "0.7.0", forRemoval = true)
@SuppressWarnings("unchecked")
public static > B newBuilder(ClassLoader classLoader) {
- if (JacksonVersion.JACKSON_3.isOnClasspath(classLoader)) {
- return (B) DoclingServeJackson3Client.builder();
- }
- else if (JacksonVersion.JACKSON_2.isOnClasspath(classLoader)) {
- return (B) DoclingServeJackson2Client.builder();
- }
-
- throw new IllegalStateException("""
- Neither Jackson 2 nor Jackson 3 is on the classpath. You must add one of the following dependencies:
-
- For Jackson 2:
- Maven: com.fasterxml.jackson.core:jackson-databind
- Gradle: implementation("com.fasterxml.jackson.core:jackson-databind:")
-
- For Jackson 3:
- Maven: tools.jackson.core:jackson-databind
- Gradle: implementation("tools.jackson.core:jackson-databind:")
- """);
+ return (B) DoclingServeClient.builderFor(classLoader);
}
/**
@@ -68,44 +48,14 @@ else if (JacksonVersion.JACKSON_2.isOnClasspath(classLoader)) {
* for {@code DoclingServeJackson2Client}. If neither are found, an
* {@link IllegalStateException} is thrown.
*
- * This method utilizes generics to support custom implementations of
- * {@link DoclingServeClient} and {@link DoclingServeClientBuilder}.
- *
* @param the type of {@link DoclingServeClient} to be created by the builder
* @param the type of {@link DoclingServeClientBuilder} to be returned
* @return a compatible {@link DoclingServeClientBuilder} instance
* @throws IllegalStateException if neither Jackson 2 nor Jackson 3 is available on the classpath
+ * @deprecated Use {@link DoclingServeClient#builder()} instead.
*/
+ @Deprecated(since = "0.7.0", forRemoval = true)
public static > B newBuilder() {
return newBuilder(Thread.currentThread().getContextClassLoader());
}
-
- @Override
- public > B getBuilder() {
- return (B) newBuilder();
- }
-
- private enum JacksonVersion {
- JACKSON_2("com.fasterxml.jackson.databind.json.JsonMapper"),
- JACKSON_3("tools.jackson.databind.json.JsonMapper");
-
- private final String jacksonClassName;
-
- JacksonVersion(String jacksonClassName) {
- this.jacksonClassName = jacksonClassName;
- }
-
- private boolean isOnClasspath() {
- return isOnClasspath(Thread.currentThread().getContextClassLoader());
- }
-
- private boolean isOnClasspath(ClassLoader classLoader) {
- try {
- Class.forName(this.jacksonClassName, false, classLoader);
- return true;
- } catch (ClassNotFoundException e) {
- return false;
- }
- }
- }
}
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientProvider.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientProvider.java
new file mode 100644
index 00000000..312c96cc
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientProvider.java
@@ -0,0 +1,29 @@
+package ai.docling.serve.client;
+
+import ai.docling.serve.api.DoclingServeApi;
+import ai.docling.serve.api.DoclingServeApiConfig;
+import ai.docling.serve.api.spi.DoclingServeApiProvider;
+
+/**
+ * The {@link DoclingServeApiProvider} of the {@code docling-serve-client} module.
+ *
+ * It creates a {@link DoclingServeJackson3Client} or a {@link DoclingServeJackson2Client}, depending on
+ * which version of Jackson is on the classpath (Jackson 3 is preferred). It honors every option of
+ * {@link DoclingServeApiConfig}.
+ *
+ *
For client-specific settings, such as the HTTP client, use {@link DoclingServeClient#builder()} instead of {@link DoclingServeApi#builder()}. To customize the JSON mapper,
+ * use {@link DoclingServeJackson3Client#builder()} or {@link DoclingServeJackson2Client#builder()}.
+ */
+public final class DoclingServeClientProvider implements DoclingServeApiProvider {
+ /**
+ * {@inheritDoc}
+ *
+ * @throws IllegalStateException if neither Jackson 2 nor Jackson 3 is on the classpath
+ */
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ return DoclingServeClient.builder()
+ .config(config)
+ .build();
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeJackson2Client.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeJackson2Client.java
index db3bbd74..14d140c0 100644
--- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeJackson2Client.java
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeJackson2Client.java
@@ -25,6 +25,7 @@ private DoclingServeJackson2Client(Builder builder) {
}
@Override
+ @SuppressWarnings("removal")
public Builder toBuilder() {
return new Builder(this);
}
@@ -37,9 +38,12 @@ public Builder toBuilder() {
* various properties, such as the JSON mapper builder, before creating a new
* {@link DoclingServeJackson2Client}.
*
+ *
Use this builder to customize the JSON mapper with {@link Builder#jsonParser(JsonMapper.Builder)}.
+ * Otherwise prefer {@link DoclingServeClient#builder()}, which detects the version of Jackson on the classpath.
+ *
* @return a new {@link Builder} instance for configuring and building a {@link DoclingServeJackson2Client}
*/
- static Builder builder() {
+ public static Builder builder() {
return new Builder();
}
@@ -47,7 +51,8 @@ static Builder builder() {
protected T readValue(String json, Class valueType) {
try {
return this.jsonMapper.readValue(json, valueType);
- } catch (JsonProcessingException e) {
+ }
+ catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@@ -58,7 +63,8 @@ protected String writeValueAsString(T value) {
return prettyPrintJson() ?
this.jsonMapper.writerWithDefaultPrettyPrinter().writeValueAsString(value) :
this.jsonMapper.writeValueAsString(value);
- } catch (JsonProcessingException e) {
+ }
+ catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
@@ -76,6 +82,7 @@ public static final class Builder extends DoclingServeClientBuilderThis method serves as the entry point for creating a new {@link DoclingServeJackson3Client.Builder}
* to customize and build a {@code DoclingServeJackson3Client}.
*
+ * Use this builder to customize the JSON mapper with {@link Builder#jsonParser(JsonMapper.Builder)}.
+ * Otherwise prefer {@link DoclingServeClient#builder()}, which detects the version of Jackson on the classpath.
+ *
* @return a new {@link Builder} instance
*/
- static Builder builder() {
+ public static Builder builder() {
return new Builder();
}
diff --git a/docling-serve/docling-serve-client/src/main/java/module-info.java b/docling-serve/docling-serve-client/src/main/java/module-info.java
index 335d6474..f31be4cc 100644
--- a/docling-serve/docling-serve-client/src/main/java/module-info.java
+++ b/docling-serve/docling-serve-client/src/main/java/module-info.java
@@ -1,6 +1,6 @@
module ai.docling.serve.client {
requires transitive ai.docling.serve.api;
- requires transitive org.slf4j;
+ requires org.slf4j;
requires static org.jspecify;
requires static com.fasterxml.jackson.core;
requires static com.fasterxml.jackson.databind;
@@ -9,5 +9,6 @@
requires java.net.http;
exports ai.docling.serve.client;
- provides ai.docling.serve.api.spi.DoclingServeApiBuilderFactory with ai.docling.serve.client.DoclingServeClientBuilderFactory;
+
+ provides ai.docling.serve.api.spi.DoclingServeApiProvider with ai.docling.serve.client.DoclingServeClientProvider;
}
diff --git a/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory b/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory
deleted file mode 100644
index a59ac66c..00000000
--- a/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory
+++ /dev/null
@@ -1 +0,0 @@
-ai.docling.serve.client.DoclingServeClientBuilderFactory
diff --git a/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider b/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider
new file mode 100644
index 00000000..0d408381
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider
@@ -0,0 +1 @@
+ai.docling.serve.client.DoclingServeClientProvider
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java
new file mode 100644
index 00000000..183cd8fc
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java
@@ -0,0 +1,159 @@
+package ai.docling.serve.client;
+
+import static ai.docling.serve.api.DoclingServeApiConfig.API_KEY;
+import static ai.docling.serve.api.DoclingServeApiConfig.ASYNC_EXECUTOR;
+import static ai.docling.serve.api.DoclingServeApiConfig.ASYNC_POLL_INTERVAL;
+import static ai.docling.serve.api.DoclingServeApiConfig.ASYNC_TIMEOUT;
+import static ai.docling.serve.api.DoclingServeApiConfig.BASE_URL;
+import static ai.docling.serve.api.DoclingServeApiConfig.CONNECT_TIMEOUT;
+import static ai.docling.serve.api.DoclingServeApiConfig.LOG_REQUESTS;
+import static ai.docling.serve.api.DoclingServeApiConfig.LOG_RESPONSES;
+import static ai.docling.serve.api.DoclingServeApiConfig.PRETTY_PRINT;
+import static ai.docling.serve.api.DoclingServeApiConfig.READ_TIMEOUT;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.temporaryRedirect;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.concurrent.Executor;
+
+import org.junit.jupiter.api.Test;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+import ai.docling.serve.api.DoclingServeApi;
+import ai.docling.serve.api.DoclingServeApiConfig;
+import ai.docling.serve.api.health.HealthCheckResponse;
+
+/**
+ * Tests for {@link DoclingServeClient#config()}, and for the settings kept by the {@code toBuilder()}
+ * method of the client.
+ */
+abstract class AbstractDoclingServeClientConfigTests {
+ // These tests never run async operations
+ private static final Executor EXECUTOR = command -> {
+ throw new UnsupportedOperationException("Not expected to run");
+ };
+
+ protected abstract WireMockExtension getWireMock();
+
+ protected abstract DoclingServeClient.DoclingServeClientBuilder, ?> newClientBuilder();
+
+ @Test
+ void configReportsEverySetting() {
+ var config = fullyConfiguredClient().config();
+
+ assertThat(config)
+ .returns(URI.create(getWireMock().baseUrl()), DoclingServeApiConfig::baseUrl)
+ .returns("key", DoclingServeApiConfig::apiKey)
+ .returns(true, DoclingServeApiConfig::logRequests)
+ .returns(true, DoclingServeApiConfig::logResponses)
+ .returns(true, DoclingServeApiConfig::prettyPrint)
+ .returns(Duration.ofSeconds(7), DoclingServeApiConfig::connectTimeout)
+ .returns(Duration.ofSeconds(11), DoclingServeApiConfig::readTimeout)
+ .returns(Duration.ofSeconds(3), DoclingServeApiConfig::asyncPollInterval)
+ .returns(Duration.ofSeconds(13), DoclingServeApiConfig::asyncTimeout)
+ .returns(EXECUTOR, DoclingServeApiConfig::asyncExecutor);
+
+ assertThat(config.explicitlySetOptions())
+ .containsExactly(BASE_URL, API_KEY, LOG_REQUESTS, LOG_RESPONSES, PRETTY_PRINT, CONNECT_TIMEOUT, READ_TIMEOUT, ASYNC_POLL_INTERVAL, ASYNC_TIMEOUT, ASYNC_EXECUTOR);
+ }
+
+ @Test
+ void configOfAnUnconfiguredClientReportsTheDefaults() {
+ var config = newClientBuilder()
+ .build()
+ .config();
+
+ assertThat(config)
+ .returns(URI.create("http://localhost:5001"), DoclingServeApiConfig::baseUrl)
+ .returns(null, DoclingServeApiConfig::apiKey)
+ .returns(false, DoclingServeApiConfig::logRequests)
+ .returns(false, DoclingServeApiConfig::logResponses)
+ .returns(false, DoclingServeApiConfig::prettyPrint)
+ .returns(Duration.ofSeconds(5), DoclingServeApiConfig::connectTimeout)
+ .returns(Duration.ofSeconds(30), DoclingServeApiConfig::readTimeout)
+ .returns(Duration.ofSeconds(2), DoclingServeApiConfig::asyncPollInterval)
+ .returns(Duration.ofMinutes(5), DoclingServeApiConfig::asyncTimeout)
+ .returns(null, DoclingServeApiConfig::asyncExecutor);
+
+ assertThat(config.explicitlySetOptions()).isEmpty();
+ }
+
+ @Test
+ void configRoundTripsThroughTheProvider() {
+ var config = fullyConfiguredClient().config();
+
+ assertThat(config.toBuilder().build().config()).isEqualTo(config);
+ }
+
+ @Test
+ void clientBuiltThroughTheProviderReportsOnlyTheOptionsThatWereSet() {
+ var client = DoclingServeApi.builder()
+ .baseUrl(getWireMock().baseUrl())
+ .apiKey("key")
+ .build();
+
+ assertThat(client.config().explicitlySetOptions()).containsExactly(BASE_URL, API_KEY);
+ }
+
+ @Test
+ void configReplacesTheSharedOptions() {
+ var config = DoclingServeApi.builder()
+ .apiKey("key")
+ .config();
+
+ var client = newClientBuilder()
+ .logRequests()
+ .config(config)
+ .build();
+
+ assertThat(client.config()).isEqualTo(config);
+ }
+
+ @Test
+ @SuppressWarnings("removal")
+ void toBuilderKeepsEverySetting() {
+ var client = fullyConfiguredClient();
+
+ assertThat(client.toBuilder().build().config()).isEqualTo(client.config());
+ }
+
+ @Test
+ @SuppressWarnings("removal")
+ void toBuilderKeepsFollowingRedirects() {
+ getWireMock().stubFor(get(urlPathEqualTo("/health")).willReturn(temporaryRedirect("/moved/health")));
+ getWireMock().stubFor(get(urlPathEqualTo("/moved/health")).willReturn(okJson("{\"status\": \"ok\"}")));
+
+ var client = newClientBuilder()
+ .baseUrl(getWireMock().baseUrl())
+ .build();
+
+ // The original client follows the redirect, so a copy failing to do so can only be the copy's fault
+ assertThat(client.health())
+ .extracting(HealthCheckResponse::getStatus)
+ .isEqualTo("ok");
+
+ assertThat(client.toBuilder().build().health())
+ .extracting(HealthCheckResponse::getStatus)
+ .isEqualTo("ok");
+ }
+
+ private DoclingServeClient fullyConfiguredClient() {
+ return newClientBuilder()
+ .baseUrl(getWireMock().baseUrl())
+ .apiKey("key")
+ .logRequests()
+ .logResponses()
+ .prettyPrint()
+ .connectTimeout(Duration.ofSeconds(7))
+ .readTimeout(Duration.ofSeconds(11))
+ .asyncPollInterval(Duration.ofSeconds(3))
+ .asyncTimeout(Duration.ofSeconds(13))
+ .asyncExecutor(EXECUTOR)
+ .build();
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java
index efc33b10..030aff33 100644
--- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientTests.java
@@ -96,7 +96,6 @@
import ai.docling.serve.api.validation.ValidationError;
import ai.docling.serve.api.validation.ValidationErrorDetail;
import ai.docling.serve.api.validation.ValidationException;
-import ai.docling.serve.client.DoclingServeClient.DoclingServeClientBuilder;
import ai.docling.testcontainers.serve.DoclingServeContainer;
import ai.docling.testcontainers.serve.config.DoclingServeContainerConfig;
@@ -162,17 +161,14 @@ private String writeValueAsString(T value) {
@Test
void builderWorks() {
- var clientBuilder = DoclingServeApi.builder()
+ var client = DoclingServeApi.builder()
.logRequests()
.logResponses()
.prettyPrint()
- .baseUrl(doclingContainer.getApiUrl());
-
- assertThat(clientBuilder)
- .isNotNull()
- .isInstanceOf(DoclingServeClientBuilder.class);
+ .baseUrl(doclingContainer.getApiUrl())
+ .build();
- assertThat(clientBuilder.build())
+ assertThat(client)
.isNotNull()
.isInstanceOf(DoclingServeClient.class);
}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/ClassHidingClassLoader.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/ClassHidingClassLoader.java
new file mode 100644
index 00000000..62084867
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/ClassHidingClassLoader.java
@@ -0,0 +1,32 @@
+package ai.docling.serve.client;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * A class loader hiding the given classes, to simulate a missing dependency.
+ */
+final class ClassHidingClassLoader extends ClassLoader {
+ private final Set classesToHide = new HashSet<>();
+
+ ClassHidingClassLoader(String... classesToHide) {
+ this(Thread.currentThread().getContextClassLoader(), classesToHide);
+ }
+
+ ClassHidingClassLoader(ClassLoader parent, String... classesToHide) {
+ super(parent);
+
+ if (classesToHide != null) {
+ this.classesToHide.addAll(Set.of(classesToHide));
+ }
+ }
+
+ @Override
+ public Class> loadClass(String name) throws ClassNotFoundException {
+ if (this.classesToHide.contains(name)) {
+ throw new ClassNotFoundException("Class %s not found".formatted(name));
+ }
+
+ return super.loadClass(name);
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderFactoryTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderFactoryTests.java
index bee8bf82..37b8c008 100644
--- a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderFactoryTests.java
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderFactoryTests.java
@@ -1,77 +1,29 @@
package ai.docling.serve.client;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-
-import ai.docling.serve.client.DoclingServeClient.DoclingServeClientBuilder;
+/**
+ * Tests that the deprecated {@link DoclingServeClientBuilderFactory} keeps delegating to {@link DoclingServeClient#builder()}.
+ */
+@SuppressWarnings({
+ "removal",
+ "rawtypes",
+ "unchecked"
+})
class DoclingServeClientBuilderFactoryTests {
@Test
- void correctBuilderWhenBothArePresent() {
- assertThat(DoclingServeClientBuilderFactory.newBuilder())
- .isNotNull()
+ void newBuilderDetectsJackson() {
+ assertThat(DoclingServeClientBuilderFactory.newBuilder())
.isExactlyInstanceOf(DoclingServeJackson3Client.Builder.class);
}
@Test
- void noBuilderWhenNeitherArePresent() {
- var classLoader = new ClassHidingClassLoader("com.fasterxml.jackson.databind.json.JsonMapper", "tools.jackson.databind.json.JsonMapper");
-
- assertThatExceptionOfType(IllegalStateException.class)
- .isThrownBy(() -> DoclingServeClientBuilderFactory.newBuilder(classLoader))
- .withMessageContaining("Neither Jackson 2 nor Jackson 3 is on the classpath")
- .withMessageContaining("com.fasterxml.jackson.core:jackson-databind")
- .withMessageContaining("tools.jackson.core:jackson-databind");
- }
-
- @ParameterizedTest
- @MethodSource("correctBuilderArguments")
- void correctBuilder(String classToHide, Class> expectedBuilderClass) {
- var classLoader = new ClassHidingClassLoader(classToHide);
-
- assertThat(DoclingServeClientBuilderFactory.newBuilder(classLoader))
- .isNotNull()
- .isExactlyInstanceOf(expectedBuilderClass);
- }
-
- static Stream correctBuilderArguments() {
- return Stream.of(
- Arguments.of("com.fasterxml.jackson.databind.json.JsonMapper", DoclingServeJackson3Client.Builder.class),
- Arguments.of("tools.jackson.databind.json.JsonMapper", DoclingServeJackson2Client.Builder.class)
- );
- }
-
- private static class ClassHidingClassLoader extends ClassLoader {
- private final Set classedToHide = new HashSet<>();
-
- public ClassHidingClassLoader(String... classesToHide) {
- this(Thread.currentThread().getContextClassLoader(), classesToHide);
- }
-
- public ClassHidingClassLoader(ClassLoader parent, String... classesToHide) {
- super(parent);
-
- if (classesToHide != null) {
- this.classedToHide.addAll(Set.of(classesToHide));
- }
- }
-
- @Override
- public Class> loadClass(String name) throws ClassNotFoundException {
- if (this.classedToHide.contains(name)) {
- throw new ClassNotFoundException("Class %s not found".formatted(name));
- }
+ void newBuilderUsesTheGivenClassLoader() {
+ var classLoader = new ClassHidingClassLoader("tools.jackson.databind.json.JsonMapper");
- return super.loadClass(name);
- }
+ assertThat(DoclingServeClientBuilderFactory.newBuilder(classLoader))
+ .isExactlyInstanceOf(DoclingServeJackson2Client.Builder.class);
}
}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderTests.java
new file mode 100644
index 00000000..a3c11e84
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderTests.java
@@ -0,0 +1,91 @@
+package ai.docling.serve.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+import java.lang.reflect.Method;
+import java.net.http.HttpClient;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import ai.docling.serve.api.DoclingServeApi;
+import ai.docling.serve.api.DoclingServeApiBuilder;
+import ai.docling.serve.client.DoclingServeClient.DoclingServeClientBuilder;
+
+/**
+ * Tests for the Jackson detection of {@link DoclingServeClient#builder()}.
+ */
+class DoclingServeClientBuilderTests {
+ private static final String JACKSON_2 = "com.fasterxml.jackson.databind.json.JsonMapper";
+ private static final String JACKSON_3 = "tools.jackson.databind.json.JsonMapper";
+
+ @Test
+ void jackson3IsPreferredWhenBothArePresent() {
+ assertThat(DoclingServeClient.builder()).isExactlyInstanceOf(DoclingServeJackson3Client.Builder.class);
+ }
+
+ @ParameterizedTest
+ @MethodSource("detectedBuilders")
+ void builderMatchesTheJacksonOnTheClasspath(String hiddenClass, Class> expectedBuilder) {
+ assertThat(DoclingServeClient.builderFor(new ClassHidingClassLoader(hiddenClass))).isExactlyInstanceOf(expectedBuilder);
+ }
+
+ @Test
+ void failsWhenNeitherJacksonIsPresent() {
+ var classLoader = new ClassHidingClassLoader(JACKSON_2, JACKSON_3);
+
+ assertThatIllegalStateException()
+ .isThrownBy(() -> DoclingServeClient.builderFor(classLoader))
+ .withMessageContaining("Neither Jackson 2 nor Jackson 3 is on the classpath")
+ .withMessageContaining("com.fasterxml.jackson.core:jackson-databind")
+ .withMessageContaining("tools.jackson.core:jackson-databind");
+ }
+
+ @Test
+ void clientSpecificSettingsChainWithoutKnowingTheJacksonVersion() {
+ // Compiling this chain on the wildcard type returned by builder() is part of what is tested
+ DoclingServeApi api = DoclingServeClient.builder()
+ .baseUrl("http://localhost:5001")
+ .httpClientBuilder(HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(20)))
+ .logRequests()
+ .build();
+
+ assertThat(api).isExactlyInstanceOf(DoclingServeJackson3Client.class);
+ }
+
+ @Test
+ void everySharedOptionCanBeSetOnTheClientBuilder() {
+ var clientBuilderMethods = Arrays.stream(DoclingServeClientBuilder.class.getMethods())
+ .map(DoclingServeClientBuilderTests::signature)
+ .collect(Collectors.toSet());
+
+ var sharedSetters = Arrays.stream(DoclingServeApiBuilder.class.getMethods())
+ .filter(method -> method.getDeclaringClass() == DoclingServeApiBuilder.class)
+ .filter(method -> !Set.of("config", "build").contains(method.getName()))
+ .map(DoclingServeClientBuilderTests::signature)
+ .toList();
+
+ assertThat(sharedSetters).isNotEmpty();
+ assertThat(clientBuilderMethods).containsAll(sharedSetters);
+ }
+
+ static Stream detectedBuilders() {
+ return Stream.of(
+ Arguments.of(JACKSON_3, DoclingServeJackson2Client.Builder.class), Arguments.of(JACKSON_2, DoclingServeJackson3Client.Builder.class));
+ }
+
+ private static String signature(Method method) {
+ return "%s(%s)".formatted(
+ method.getName(), Arrays.stream(method.getParameterTypes())
+ .map(Class::getName)
+ .collect(Collectors.joining(", ")));
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientProviderTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientProviderTests.java
new file mode 100644
index 00000000..125a33c5
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientProviderTests.java
@@ -0,0 +1,118 @@
+package ai.docling.serve.client;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.okJson;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+import ai.docling.serve.api.DoclingServeApi;
+import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
+import ai.docling.serve.api.convert.request.source.HttpSource;
+import ai.docling.serve.api.health.HealthCheckResponse;
+import ai.docling.serve.client.operations.HttpOperations;
+
+/**
+ * Tests that {@link DoclingServeClientProvider}, discovered through {@link DoclingServeApi#builder()},
+ * applies the configuration to the client it creates.
+ */
+class DoclingServeClientProviderTests {
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort())
+ .build();
+
+ @Test
+ void providerIsDiscoveredAndPrefersJackson3() {
+ var client = DoclingServeApi.builder()
+ .baseUrl(wireMock.baseUrl())
+ .build();
+
+ assertThat(client).isExactlyInstanceOf(DoclingServeJackson3Client.class);
+ }
+
+ @Test
+ void providerDeclaresNoUnsupportedOption() {
+ assertThat(new DoclingServeClientProvider().unsupportedOptions()).isEmpty();
+ }
+
+ @Test
+ void baseUrlAndApiKeyAreApplied() {
+ wireMock.stubFor(
+ get(urlPathEqualTo("/path/health"))
+ .withHeader(HttpOperations.API_KEY_HEADER_NAME, equalTo("key"))
+ .willReturn(okJson("{\"status\": \"ok\"}")));
+
+ var client = DoclingServeApi.builder()
+ .baseUrl(wireMock.baseUrl() + "/path")
+ .apiKey("key")
+ .build();
+
+ assertThat(client.health())
+ .extracting(HealthCheckResponse::getStatus)
+ .isEqualTo("ok");
+
+ wireMock.verify(1, getRequestedFor(urlPathEqualTo("/path/health")).withHeader(HttpOperations.API_KEY_HEADER_NAME, equalTo("key")));
+ }
+
+ @Test
+ void asyncExecutorIsApplied() throws Exception {
+ wireMock.stubFor(post(urlPathEqualTo("/v1/convert/source/async")).willReturn(okJson(taskStatus("success"))));
+ wireMock.stubFor(get(urlPathEqualTo("/v1/status/poll/task-1")).willReturn(okJson(taskStatus("success"))));
+ wireMock.stubFor(get(urlPathEqualTo("/v1/result/task-1")).willReturn(okJson("""
+ {
+ "document": { "filename": "dev.html", "md_content": "# Dev" },
+ "status": "success",
+ "errors": [],
+ "processing_time": 0.5,
+ "timings": {}
+ }
+ """)));
+
+ var executions = new AtomicInteger();
+
+ var client = DoclingServeApi.builder()
+ .baseUrl(wireMock.baseUrl())
+ .asyncPollInterval(Duration.ofMillis(50))
+ .asyncExecutor(command -> {
+ executions.incrementAndGet();
+ new Thread(command).start();
+ })
+ .build();
+
+ client.convertSourceAsync(convertRequest())
+ .toCompletableFuture()
+ .get(10, TimeUnit.SECONDS);
+
+ // If the provider dropped the executor, the client would use the CompletableFuture default and this would be 0
+ assertThat(executions).hasPositiveValue();
+ }
+
+ private static ConvertDocumentRequest convertRequest() {
+ return ConvertDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://docs.arconia.io/arconia-cli/latest/development/dev/")).build())
+ .build();
+ }
+
+ private static String taskStatus(String status) {
+ return """
+ {
+ "task_id": "task-1",
+ "task_status": "%s"
+ }
+ """.formatted(status);
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson2ClientConfigTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson2ClientConfigTests.java
new file mode 100644
index 00000000..32f95567
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson2ClientConfigTests.java
@@ -0,0 +1,27 @@
+package ai.docling.serve.client;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+/**
+ * Config tests for {@link DoclingServeJackson2Client}.
+ */
+class DoclingServeJackson2ClientConfigTests extends AbstractDoclingServeClientConfigTests {
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort())
+ .build();
+
+ @Override
+ protected WireMockExtension getWireMock() {
+ return wireMock;
+ }
+
+ @Override
+ protected DoclingServeClient.DoclingServeClientBuilder, ?> newClientBuilder() {
+ return DoclingServeJackson2Client.builder();
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson3ClientConfigTests.java b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson3ClientConfigTests.java
new file mode 100644
index 00000000..ba290fdd
--- /dev/null
+++ b/docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson3ClientConfigTests.java
@@ -0,0 +1,27 @@
+package ai.docling.serve.client;
+
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
+
+/**
+ * Config tests for {@link DoclingServeJackson3Client}.
+ */
+class DoclingServeJackson3ClientConfigTests extends AbstractDoclingServeClientConfigTests {
+ @RegisterExtension
+ static WireMockExtension wireMock = WireMockExtension.newInstance()
+ .options(wireMockConfig().dynamicPort())
+ .build();
+
+ @Override
+ protected WireMockExtension getWireMock() {
+ return wireMock;
+ }
+
+ @Override
+ protected DoclingServeClient.DoclingServeClientBuilder, ?> newClientBuilder() {
+ return DoclingServeJackson3Client.builder();
+ }
+}
diff --git a/docs/src/doc/docs/docling-serve/serve-api-provider-migration.md b/docs/src/doc/docs/docling-serve/serve-api-provider-migration.md
new file mode 100644
index 00000000..743728fd
--- /dev/null
+++ b/docs/src/doc/docs/docling-serve/serve-api-provider-migration.md
@@ -0,0 +1,290 @@
+# Migrating to `DoclingServeApiProvider`
+
+Docling Java {{ gradle.project_version }} replaces the `DoclingServeApiBuilderFactory` service provider interface (SPI) with `DoclingServeApiProvider`. This page explains how to move an implementation to the new SPI, and how to update code that builds a `DoclingServeApi`.
+
+This page is for you if you:
+
+- implement `DoclingServeApiBuilderFactory`, `DoclingServeApi` or `DoclingServeApi.DoclingApiBuilder`, or
+- call `DoclingServeApi.builder()` and declare the builder with its type, use client-specific methods such as `httpClientBuilder(...)`, or copy an API with `toBuilder()`.
+
+If you only chain calls such as `DoclingServeApi.builder().baseUrl(...).build()`, nothing changes for you.
+
+## Why the SPI changed
+
+`DoclingServeApiBuilderFactory` returned a `DoclingApiBuilder`, an interface that every implementation had to implement. Each new configuration option meant a new method on that interface, which either broke every implementation or needed a `default` method throwing `UnsupportedOperationException`.
+
+A `DoclingServeApiProvider` instead receives an immutable `DoclingServeApiConfig`, a final class owned by `docling-serve-api`. New options are added to that class, so they never break an existing provider.
+
+## Compatibility
+
+`DoclingServeApiBuilderFactory`, `DoclingServeApi.DoclingApiBuilder` and `DoclingServeApi.toBuilder()` are deprecated for removal in a future release, as is `DoclingServeClientBuilderFactory` in `docling-serve-client`. Until then:
+
+- A `DoclingServeApiBuilderFactory` is still used when no `DoclingServeApiProvider` is available. Options added after its deprecation, such as `asyncExecutor`, make `build()` throw an `UnsupportedConfigurationException` through it, because the old builder has no way to receive them.
+- When both are available, the `DoclingServeApiProvider` is used, and a warning naming the ignored factory is logged every time an API is built.
+
+`DoclingServeApi` also has a new abstract `config()` method, which every implementation must add: see [Update your `DoclingServeApi` implementation](#update-your-doclingserveapi-implementation).
+
+## Replace the factory with a provider
+
+A factory returned a builder, and `docling-serve-api` applied the caller's settings to it. A provider receives the settings and builds the API itself.
+
+Before:
+
+```java
+public final class MyFactory implements DoclingServeApiBuilderFactory {
+ @Override
+ @SuppressWarnings("unchecked")
+ public > B getBuilder() {
+ return (B) MyDoclingServeApi.builder();
+ }
+}
+```
+
+After, mapping every option of `DoclingServeApiConfig` onto your own builder:
+
+```java
+public final class MyProvider implements DoclingServeApiProvider {
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ var builder = MyDoclingServeApi.builder()
+ .baseUrl(config.baseUrl())
+ .apiKey(config.apiKey())
+ .logRequests(config.logRequests())
+ .logResponses(config.logResponses())
+ .prettyPrint(config.prettyPrint())
+ .connectTimeout(config.connectTimeout())
+ .readTimeout(config.readTimeout())
+ .asyncPollInterval(config.asyncPollInterval())
+ .asyncTimeout(config.asyncTimeout());
+
+ // asyncExecutor() is null when not set: only pass it on when it is set
+ Optional.ofNullable(config.asyncExecutor())
+ .ifPresent(builder::asyncExecutor);
+
+ return builder.build();
+ }
+}
+```
+
+Every accessor returns the effective value: the caller's setting, or the default when the caller didn't set it. The only exceptions are `apiKey()` and `asyncExecutor()`, which return `null` when not set.
+
+!!! warning
+ When `asyncExecutor()` returns `null`, keep your implementation's default behavior. Don't replace it with `ForkJoinPool.commonPool()`: `CompletableFuture` doesn't always use the common pool by default, and `CompletableFuture.delayedExecutor(...)` doesn't guard against a common pool without worker threads, for example on a single-CPU container.
+
+A provider must be `public` and have a `public` no-argument constructor.
+
+If your implementation can't support an option, declare it instead of dropping it silently: see [Declare the options you can't honor](#declare-the-options-you-cant-honor).
+
+## Register the provider
+
+Rename the service file, and point it to the provider:
+
+```text
+# Before: META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory
+com.example.MyFactory
+
+# After: META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider
+com.example.MyProvider
+```
+
+If you use Java modules, change the `provides` clause in `module-info.java`:
+
+```java
+// Before
+provides ai.docling.serve.api.spi.DoclingServeApiBuilderFactory with com.example.MyFactory;
+
+// After
+provides ai.docling.serve.api.spi.DoclingServeApiProvider with com.example.MyProvider;
+```
+
+Remove the old registration: if both are present, the factory is ignored and a warning is logged every time an API is built.
+
+## Declare the options you can't honor
+
+Some options may not make sense for your implementation. Declare them in `unsupportedOptions()`, together with what should happen when a caller explicitly sets them:
+
+| Value | When a caller explicitly sets the option | Use it when |
+|-------|------------------------------------------|-------------|
+| `IGNORE` | Nothing happens. | The option has no meaning for your implementation, and ignoring it can't surprise anyone. |
+| `WARN` | A warning naming the option and your provider is logged. | The caller may expect an effect that your implementation achieves differently. |
+| `FAIL` | `build()` throws an `UnsupportedConfigurationException`. | Ignoring the option would change the behavior the caller asked for. |
+
+For example, a reactive implementation that manages its own threads and doesn't poll for task status:
+
+```java
+@Override
+public Map, Unsupported> unsupportedOptions() {
+ return Map.of(
+ DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.WARN,
+ DoclingServeApiConfig.ASYNC_POLL_INTERVAL, Unsupported.IGNORE);
+}
+```
+
+A few rules apply:
+
+- Options left at their default value never trigger anything, so declaring an option doesn't bother callers who don't use it.
+- Options absent from the map are assumed to be honored. Nothing checks it, so only leave out the options you actually apply.
+- Never declare the base URL, the API key or the timeouts as `IGNORE` or `WARN`: silently dropping them causes failures far from their cause. If your implementation can't honor one of them, declare it `FAIL`.
+- The declaration is only enforced by `DoclingServeApi.builder()`. Code that uses your implementation's own builder bypasses it.
+
+## Update your `DoclingServeApi` implementation
+
+### Add `config()`
+
+`DoclingServeApi.config()` returns the configuration an API runs with. Callers use it to create a modified copy with `api.config().toBuilder()`, so it must report the effective value of every option: an API built from it should behave like the original.
+
+If your implementation keeps the configuration it was created from, return it:
+
+```java
+public final class MyDoclingServeApi implements DoclingServeApi {
+ private final DoclingServeApiConfig config;
+
+ private MyDoclingServeApi(DoclingServeApiConfig config) {
+ this.config = config;
+ }
+
+ @Override
+ public DoclingServeApiConfig config() {
+ return this.config;
+ }
+
+ // The other API methods are unchanged
+}
+```
+
+If it's built from its own builder, the simplest option is to keep a `DoclingServeApiBuilder` in that builder for the shared options, as the reference client does, and store the configuration it produces. Otherwise, rebuild the configuration from its settings. `config()` on `DoclingServeApiBuilder` only creates the configuration: it doesn't look up a provider.
+
+```java
+@Override
+public DoclingServeApiConfig config() {
+ var builder = DoclingServeApi.builder()
+ .baseUrl(this.baseUrl)
+ .apiKey(this.apiKey)
+ .logRequests(this.logRequests)
+ .logResponses(this.logResponses)
+ .prettyPrint(this.prettyPrint)
+ .connectTimeout(this.connectTimeout)
+ .readTimeout(this.readTimeout)
+ .asyncPollInterval(this.asyncPollInterval)
+ .asyncTimeout(this.asyncTimeout);
+
+ // Only set when configured: an unset executor must stay unset
+ Optional.ofNullable(this.asyncExecutor)
+ .ifPresent(builder::asyncExecutor);
+
+ return builder.config();
+}
+```
+
+Implementations compiled against an earlier version don't have this method, and calling `config()` on them throws an `AbstractMethodError`. Recompile them against this version.
+
+### Keep `toBuilder()` for now
+
+`DoclingServeApi.toBuilder()` is deprecated for removal, and replaced by `config().toBuilder()`. It is still abstract, so keep your existing implementation until it is removed, and add `@SuppressWarnings("removal")` to it to silence the deprecation warning.
+
+### Detach your builder from `DoclingApiBuilder`
+
+If your builder implements `DoclingServeApi.DoclingApiBuilder`, it keeps compiling, but the interface is deprecated for removal and won't gain new options. `asyncExecutor`, for example, isn't part of it.
+
+Before the interface is removed, drop the `implements DoclingApiBuilder<...>` clause and the `@Override` annotations of its methods. The methods themselves can stay. Add new options such as `asyncExecutor(Executor)` to your builder directly if your implementation supports them.
+
+## Require this version of Docling Java
+
+Shipping both a `DoclingServeApiBuilderFactory` and a `DoclingServeApiProvider` to support several versions of Docling Java isn't recommended: with this version, the factory is ignored and a warning is logged every time an API is built. Require Docling Java {{ gradle.project_version }} or later, and ship only the provider.
+
+## Update code that builds a `DoclingServeApi`
+
+### The builder type
+
+`DoclingServeApi.builder()` used to return the builder of the implementation, through a generic ` B` return type. It now returns a `DoclingServeApiBuilder`, whatever the implementation.
+
+Before:
+
+```java
+DoclingServeJackson3Client.Builder builder = DoclingServeApi.builder();
+```
+
+After:
+
+```java
+DoclingServeApiBuilder builder = DoclingServeApi.builder();
+```
+
+### Client-specific settings
+
+`DoclingServeApiBuilder` only has the options shared by every implementation. For the settings of the reference client, such as the `HttpClient`, start from `DoclingServeClient.builder()` instead. It picks Jackson 3 or Jackson 2 the same way. To customize the JSON mapper, whose type depends on the version of Jackson, use `DoclingServeJackson3Client.builder()` or `DoclingServeJackson2Client.builder()` and their `jsonParser(...)` method.
+
+Before:
+
+```java
+DoclingServeApi api = DoclingServeApi.builder()
+ .baseUrl("https://serve.example.com")
+ .httpClientBuilder(HttpClient.newBuilder().proxy(proxySelector))
+ .build();
+```
+
+After:
+
+```java
+DoclingServeApi api = DoclingServeClient.builder()
+ .baseUrl("https://serve.example.com")
+ .httpClientBuilder(HttpClient.newBuilder().proxy(proxySelector))
+ .build();
+```
+
+`DoclingServeClientBuilderFactory.newBuilder()` is deprecated for removal. Replace it with `DoclingServeClient.builder()`, which returns the same builder. If you assign the result to the builder type of a concrete client, use the `builder()` of that client instead, e.g. `DoclingServeJackson3Client.builder()`.
+
+### Copying an API
+
+Before:
+
+```java
+DoclingServeApi copy = api.toBuilder()
+ .logRequests()
+ .build();
+```
+
+After:
+
+```java
+DoclingServeApi copy = api.config()
+ .toBuilder()
+ .logRequests()
+ .build();
+```
+
+The copy is built through the provider, from the options of `DoclingServeApiConfig` only. To also keep the client-specific settings of the reference client, call `toBuilder()` on the concrete client type, which isn't deprecated:
+
+```java
+DoclingServeJackson3Client copy = client.toBuilder()
+ .logRequests()
+ .build();
+```
+
+## Check the migration
+
+These tests check that your provider is discovered, and that `config()` reports the settings the API was built with:
+
+```java
+@Test
+void providerIsDiscovered() {
+ var api = DoclingServeApi.builder()
+ .baseUrl("http://localhost:5001")
+ .build();
+
+ assertThat(api).isInstanceOf(MyDoclingServeApi.class);
+}
+
+@Test
+void configReportsTheEffectiveSettings() {
+ var api = DoclingServeApi.builder()
+ .baseUrl("http://localhost:5001")
+ .apiKey("key")
+ .readTimeout(Duration.ofSeconds(10))
+ .build();
+
+ assertThat(api.config().toBuilder().build().config()).isEqualTo(api.config());
+}
+```
+
+Also check your logs for a warning starting with `Ignoring deprecated ai.docling.serve.api.spi.DoclingServeApiBuilderFactory implementation(s)`: it means an old registration is still on the classpath.
diff --git a/docs/src/doc/docs/docling-serve/serve-api.md b/docs/src/doc/docs/docling-serve/serve-api.md
index 6ef7732d..275d64ab 100644
--- a/docs/src/doc/docs/docling-serve/serve-api.md
+++ b/docs/src/doc/docs/docling-serve/serve-api.md
@@ -7,7 +7,7 @@ with a [Docling Serve](https://github.com/docling-project/docling-serve) backend
interface. You can use any implementation of this interface to talk to a running
[Docling Serve](https://github.com/docling-project/docling-serve) instance.
-The base Java version is 17. This module has no other required dependencies, although it is compatible with both Jackson [2.x](https://github.com/FasterXML/jackson) and [3.x](https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md).
+The base Java version is 17. Its only other required dependency is [SLF4J](https://www.slf4j.org/) (`slf4j-api`), and it is compatible with both Jackson [2.x](https://github.com/FasterXML/jackson) and [3.x](https://github.com/FasterXML/jackson/blob/main/jackson3/MIGRATING_TO_JACKSON_3.md).
If you need a ready-to-use HTTP implementation, see the reference client:
- Docling Serve Client: [`docling-serve-client`](serve-client.md)
@@ -108,9 +108,45 @@ provided by the `docling-serve-client` module.
### Extension Points
-The `docling-serve-api` module uses the [Java Service Provider Interface](https://www.baeldung.com/java-spi) to define an extension point for building/customizing instances of `DoclingServeApi`. An application (or downstream framework) can create an implementation of `ai.docling.serve.api.spi.DoclingServeApiBuilderFactory` and register it via the `META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory` file, or providing an implementation using Java modules.
+The `docling-serve-api` module uses the [Java Service Provider Interface](https://www.baeldung.com/java-spi) to define an extension point for creating instances of `DoclingServeApi`. An application (or downstream framework) can implement `ai.docling.serve.api.spi.DoclingServeApiProvider` and register it via the `META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider` file, or with a `provides` clause in `module-info.java`.
-This is exactly what the `docling-serve-client` module does to provide its implementation. See [`module-info.java`](https://github.com/docling-project/docling-java/blob/main/docling-serve/docling-serve-client/src/main/java/module-info.java) and [`DoclingServeClientBuilderFactory.java`](https://github.com/docling-project/docling-java/blob/main/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java).
+`DoclingServeApi.builder()` collects the configuration into an immutable `DoclingServeApiConfig`, and `build()` hands it to the single available provider:
+
+```java
+public final class MyProvider implements DoclingServeApiProvider {
+ @Override
+ public DoclingServeApi create(DoclingServeApiConfig config) {
+ return new MyDoclingServeApi(config);
+ }
+}
+```
+
+Because providers receive a configuration object rather than implementing a builder, new options can be added in later releases without breaking existing providers. The API they create reports its configuration through `DoclingServeApi.config()`.
+
+This is exactly what the `docling-serve-client` module does to provide its implementation. See [`module-info.java`](https://github.com/docling-project/docling-java/blob/main/docling-serve/docling-serve-client/src/main/java/module-info.java) and [`DoclingServeClientProvider.java`](https://github.com/docling-project/docling-java/blob/main/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientProvider.java).
+
+#### Declaring unsupported options
+
+A provider that cannot honor an option declares it in `unsupportedOptions()`, together with what should happen when a caller explicitly sets it:
+
+- `IGNORE` — silently ignore the option.
+- `WARN` — log a warning naming the option and the provider.
+- `FAIL` — make `build()` throw an `UnsupportedConfigurationException`.
+
+Options left at their default value are never reported. For example, a reactive implementation that manages its own threads can declare the async executor as meaningless for it:
+
+```java
+@Override
+public Map, Unsupported> unsupportedOptions() {
+ return Map.of(DoclingServeApiConfig.ASYNC_EXECUTOR, Unsupported.WARN);
+}
+```
+
+Options absent from this map are assumed to be honored. Providers must not silently ignore the base URL, the API key or the timeouts.
+
+#### Migrating from `DoclingServeApiBuilderFactory`
+
+The previous SPI, `ai.docling.serve.api.spi.DoclingServeApiBuilderFactory`, is deprecated for removal. It is still used when no `DoclingServeApiProvider` is available, but options added after its deprecation (such as `asyncExecutor`) make `build()` fail through it. See [Migrating to `DoclingServeApiProvider`](serve-api-provider-migration.md) for a step-by-step guide.
### Requests: `ConvertDocumentRequest`
@@ -233,12 +269,12 @@ In the case of a request validation error (i.e. `docling-serve` throws a `422` e
## Logging and builders
-`DoclingServeApi` exposes a `toBuilder()` method so implementations can be duplicated and tweaked. Most
-client builders, including the reference client, also expose `logRequests()` and `logResponses()` for
-simple diagnostics:
+`DoclingServeApi.builder()` exposes `logRequests()` and `logResponses()` for simple diagnostics.
+To create a modified copy of an existing API, start from its configuration:
```java
-DoclingServeApi newApi = api.toBuilder()
+DoclingServeApi newApi = api.config()
+ .toBuilder()
.logRequests()
.logResponses()
.build();
diff --git a/docs/src/doc/docs/docling-serve/serve-client.md b/docs/src/doc/docs/docling-serve/serve-client.md
index 2c6be180..232d20a1 100644
--- a/docs/src/doc/docs/docling-serve/serve-client.md
+++ b/docs/src/doc/docs/docling-serve/serve-client.md
@@ -106,7 +106,7 @@ System.out.println(response.getDocument().getMarkdownContent());
## Core concepts and configuration
-### Builder factory and Jackson auto‑detection
+### Jackson auto‑detection
`DoclingServeApi.builder()` chooses an implementation based on what's on your classpath:
@@ -114,8 +114,12 @@ System.out.println(response.getDocument().getMarkdownContent());
- Else if Jackson 2 present → `DoclingServeJackson2Client`
- Otherwise → `IllegalStateException`
-Advanced: You can customize the JSON mapper via `.toBuilder().jsonParser(...)` on the concrete
-client type if you need special Jackson modules or settings.
+`DoclingServeApi.builder()` only exposes the options shared by every implementation. For
+client-specific settings, such as the `HttpClient`, start from `DoclingServeClient.builder()` instead:
+it performs the same Jackson detection and returns the builder of the detected client. To customize
+the JSON mapper, whose type depends on the version of Jackson, use `DoclingServeJackson3Client.builder()`
+or `DoclingServeJackson2Client.builder()` and their `jsonParser(...)` method. Calling `toBuilder()` on a
+concrete client keeps its JSON mapper.
### Base URL
@@ -132,13 +136,14 @@ which avoids HTTP/2 downgrade mishaps in some environments.
### HTTP client customization (timeouts, proxies, TLS)
-You can supply and tune a `java.net.http.HttpClient.Builder`:
+You can supply and tune a `java.net.http.HttpClient.Builder` through the client-specific builder:
```java
import java.net.http.HttpClient;
import java.time.Duration;
+import ai.docling.serve.client.DoclingServeClient;
-DoclingServeApi api = DoclingServeApi.builder()
+DoclingServeApi api = DoclingServeClient.builder()
.baseUrl("https://serve.example.com")
.httpClientBuilder(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20))
diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md
index a6f13cc2..7e87d493 100644
--- a/docs/src/doc/docs/whats-new.md
+++ b/docs/src/doc/docs/whats-new.md
@@ -25,7 +25,15 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a
### {{ gradle.project_version }}
-* **Custom `Executor` for async operations** — The async methods (`convertSourceAsync`, `convertSourceBatchAsync`, `convertFilesAsync`, `chunkSourceWith*ChunkerAsync`, ...) used to run on `CompletableFuture`'s default executor (usually `ForkJoinPool.commonPool()`). A new `asyncExecutor(Executor)` builder method lets you run them (task submission, status polling and result retrieval) on your own executor instead, e.g. a virtual-thread executor or one managed by your framework. When not set, the behaviour is unchanged. The client never shuts the executor down. For custom `DoclingApiBuilder` implementations, `asyncExecutor` is a `default` method that throws `UnsupportedOperationException`, so they keep compiling.
+* **Breaking: new `DoclingServeApiProvider` SPI** — Implementations are now provided through `ai.docling.serve.api.spi.DoclingServeApiProvider`, which receives an immutable `DoclingServeApiConfig`, so new options can be added without breaking implementations. A provider can declare the options it doesn't honor via `unsupportedOptions()`, to have them ignored, logged or rejected when a caller sets them. `DoclingServeApi` has a new abstract `config()` method. `DoclingServeApiBuilderFactory`, `DoclingServeApi.DoclingApiBuilder` and `DoclingServeApi.toBuilder()` are deprecated for removal; legacy factories are still used when no provider is found. `docling-serve-api` now depends on `slf4j-api`. See [Migrating to `DoclingServeApiProvider`](docling-serve/serve-api-provider-migration.md).
+* **Breaking: `DoclingServeApi.builder()` returns a `DoclingServeApiBuilder`** — It used to return the builder of the implementation through a generic ` B` return type. Fluent chains and `var` declarations are unaffected, but code declaring the builder type must be updated. Client-specific settings such as `httpClientBuilder` are available from `DoclingServeClient.builder()`, which detects the version of Jackson the same way, and the JSON mapper from `DoclingServeJackson3Client.builder()` / `DoclingServeJackson2Client.builder()`, which are now public. An API can be copied with `api.config().toBuilder()`.
+* **`DoclingServeClientBuilderFactory` is deprecated** — Use `DoclingServeApi.builder()` for the options shared by every implementation, or the new `DoclingServeClient.builder()` for client-specific settings: it returns the same builder as `DoclingServeClientBuilderFactory.newBuilder()`. The factory will be removed in a future release.
+* **`toBuilder()` of the reference client keeps the timeouts and the redirect policy** — Copying a `DoclingServeJackson2Client` or `DoclingServeJackson3Client` with `toBuilder()` used to reset `connectTimeout` and `readTimeout` to their defaults, and stop following redirects. The copy now keeps them. Other `HttpClient` settings, such as a proxy or an SSL context, are still not copied.
+* **The builders of the reference client validate values when they are set** — Setting an invalid value on the builder of `DoclingServeJackson2Client` or `DoclingServeJackson3Client`, such as `connectTimeout(Duration.ZERO)` or `httpClientBuilder(null)`, now throws an `IllegalArgumentException` from the setter instead of from `build()`. The builders also gain `config(DoclingServeApiConfig)`, which applies a whole configuration at once, and `config()` on the reference client now reports only the options that were explicitly set.
+* **Custom `Executor` for async operations** — The async methods (`convertSourceAsync`, `convertSourceBatchAsync`, `convertFilesAsync`, `chunkSourceWith*ChunkerAsync`, ...) used to run on `CompletableFuture`'s default executor (usually `ForkJoinPool.commonPool()`). A new `asyncExecutor(Executor)` builder method lets you run them (task submission, status polling and result retrieval) on your own executor instead, e.g. a virtual-thread executor or one managed by your framework. When not set, the behavior is unchanged. The client never shuts the executor down.
+
+### 0.6.6
+
* **New `DocumentRequest` sealed base class** — `ConvertDocumentRequest`, `BatchConvertDocumentRequest`, and `ChunkDocumentRequest` now extend a common `DocumentRequest` abstract class in the `ai.docling.serve.api.request` package. This enables polymorphism when working with different request types — for example, accepting a `DocumentRequest` and dispatching to the correct endpoint based on the concrete type via pattern matching.
* **New `ProcessedDocumentResponse` sealed base class** — `ConvertDocumentResponse` and `ChunkDocumentResponse` now extend a common `ProcessedDocumentResponse` abstract class in the `ai.docling.serve.api.response` package. This enables polymorphic handling of document processing responses — for example, using `ProcessedDocumentResponse` as a type bound in generic APIs that work with both conversion and chunking results.
* **`toBuilder()` on the `DocumentRequest` base type** — `DocumentRequest` (and the intermediate `ChunkDocumentRequest`) now expose `toBuilder()`, so a request can be cloned and modified through the base type without first pattern-matching on the concrete subtype. This makes it possible to inject a `source` or `target` once — polymorphically — before dispatching, e.g. `request.toBuilder().source(source).build()`.
diff --git a/docs/src/doc/mkdocs.yml b/docs/src/doc/mkdocs.yml
index b76f7bd2..f90ae734 100644
--- a/docs/src/doc/mkdocs.yml
+++ b/docs/src/doc/mkdocs.yml
@@ -62,6 +62,7 @@ nav:
- Docling Serve:
- API: docling-serve/serve-api.md
- Client: docling-serve/serve-client.md
+ - Migrating to DoclingServeApiProvider: docling-serve/serve-api-provider-migration.md
- Testing: testing.md
- Testcontainers: testcontainers.md