From 146eb598dd05e077b8a914f64a80c18fa4977617 Mon Sep 17 00:00:00 2001 From: Eric Deandrea Date: Wed, 23 Sep 2026 16:07:30 -0700 Subject: [PATCH] feat(api)!: replace DoclingServeApiBuilderFactory with DoclingServeApiProvider DoclingServeApiBuilderFactory handed out a DoclingApiBuilder, an interface every implementation had to implement. Each new configuration option was either a breaking change or a default method throwing UnsupportedOperationException. The new DoclingServeApiProvider SPI instead receives an immutable DoclingServeApiConfig, a final class owned by docling-serve-api, so options can be added without breaking implementations. - DoclingServeApi.builder() returns a DoclingServeApiBuilder collecting a DoclingServeApiConfig; build() hands it to the single available provider - Providers declare the options they don't honor via unsupportedOptions() (IGNORE, WARN or FAIL), enforced only for explicitly set options - DoclingServeApi gains an abstract config(); an API is copied with api.config().toBuilder() - DoclingServeApiBuilderFactory, DoclingApiBuilder and DoclingServeApi.toBuilder() are deprecated for removal. Legacy factories are still used when no provider is found, but options added after the deprecation, such as asyncExecutor, fail through them - docling-serve-client provides DoclingServeClientProvider. The client builder keeps a DoclingServeApiBuilder instead of duplicating every option, and validates values when they are set - DoclingServeClient.builder() detects Jackson 2 or 3 for client-specific settings; the builder() methods of the Jackson clients are now public, and DoclingServeClientBuilderFactory is deprecated for removal - toBuilder() of the reference client keeps the timeouts and the redirect policy - slf4j-api moves from docling-serve-client to docling-serve-api - Remove the unreleased default DoclingApiBuilder.asyncExecutor() from #691: asyncExecutor is now a DoclingServeApiConfig option - Add a migration guide, and set the release version to 0.7.0 BREAKING CHANGE: DoclingServeApi.builder() now returns a DoclingServeApiBuilder instead of the builder of the implementation, and DoclingServeApi has a new abstract config() method. Signed-off-by: Eric Deandrea --- .github/project.yml | 4 +- CLAUDE.md | 4 +- .../docling-serve-api/build.gradle.kts | 2 + .../ai/docling/serve/api/ConfigOption.java | 63 ++++ .../ai/docling/serve/api/DoclingServeApi.java | 97 +++--- .../serve/api/DoclingServeApiBuilder.java | 283 ++++++++++++++++ .../serve/api/DoclingServeApiConfig.java | 297 +++++++++++++++++ .../serve/api/DoclingServeApiProviders.java | 76 +++++ .../serve/api/LegacyProviderAdapter.java | 65 ++++ .../UnsupportedConfigurationException.java | 47 +++ .../spi/DoclingServeApiBuilderFactory.java | 7 + .../api/spi/DoclingServeApiProvider.java | 75 +++++ .../src/main/java/module-info.java | 2 + .../api/DoclingServeApiBuilderTests.java | 93 ++++++ .../serve/api/DoclingServeApiConfigTests.java | 210 ++++++++++++ .../api/DoclingServeApiProvidersTests.java | 72 +++++ .../serve/api/DoclingServeApiTests.java | 76 +---- .../serve/api/LegacyProviderAdapterTests.java | 158 +++++++++ .../java/ai/docling/serve/api/TestApis.java | 164 ++++++++++ .../docling-serve-client/build.gradle.kts | 2 +- .../serve/client/DoclingServeClient.java | 304 ++++++++++++------ .../DoclingServeClientBuilderFactory.java | 88 ++--- .../client/DoclingServeClientProvider.java | 29 ++ .../client/DoclingServeJackson2Client.java | 13 +- .../client/DoclingServeJackson3Client.java | 6 +- .../src/main/java/module-info.java | 5 +- ...erve.api.spi.DoclingServeApiBuilderFactory | 1 - ...ling.serve.api.spi.DoclingServeApiProvider | 1 + ...AbstractDoclingServeClientConfigTests.java | 159 +++++++++ .../AbstractDoclingServeClientTests.java | 12 +- .../serve/client/ClassHidingClassLoader.java | 32 ++ ...DoclingServeClientBuilderFactoryTests.java | 76 +---- .../DoclingServeClientBuilderTests.java | 91 ++++++ .../DoclingServeClientProviderTests.java | 118 +++++++ ...DoclingServeJackson2ClientConfigTests.java | 27 ++ ...DoclingServeJackson3ClientConfigTests.java | 27 ++ .../serve-api-provider-migration.md | 290 +++++++++++++++++ docs/src/doc/docs/docling-serve/serve-api.md | 50 ++- .../doc/docs/docling-serve/serve-client.md | 15 +- docs/src/doc/docs/whats-new.md | 10 +- docs/src/doc/mkdocs.yml | 1 + 41 files changed, 2756 insertions(+), 396 deletions(-) create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/ConfigOption.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiBuilder.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiConfig.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApiProviders.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/LegacyProviderAdapter.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/UnsupportedConfigurationException.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/spi/DoclingServeApiProvider.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiBuilderTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiConfigTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/DoclingServeApiProvidersTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/LegacyProviderAdapterTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/TestApis.java create mode 100644 docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientProvider.java delete mode 100644 docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiBuilderFactory create mode 100644 docling-serve/docling-serve-client/src/main/resources/META-INF/services/ai.docling.serve.api.spi.DoclingServeApiProvider create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/AbstractDoclingServeClientConfigTests.java create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/ClassHidingClassLoader.java create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientBuilderTests.java create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeClientProviderTests.java create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson2ClientConfigTests.java create mode 100644 docling-serve/docling-serve-client/src/test/java/ai/docling/serve/client/DoclingServeJackson3ClientConfigTests.java create mode 100644 docs/src/doc/docs/docling-serve/serve-api-provider-migration.md 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 subscriber) { - if (logRequests) { + if (config.logRequests()) { LOG.info("\n→ REQUEST BODY: \n{}", this.stringContent); } @@ -451,149 +479,196 @@ public void subscribe(Subscriber 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