Skip to content

Commit e47f3f9

Browse files
authored
feat: Introduce API extension point and enhance builder usage (#252)
- Added SPI-based extension points for `DoclingServeApi` using `DoclingServeApiBuilderFactory`. - Replaced `DoclingServeClientBuilderFactory` with a unified `DoclingServeApi.builder()` factory method. - Updated API and client layers to align with the new builder factory model. - Simplified documentation and examples to reflect the updated API usage. - Enhanced test coverage for SPI-based customization and factory functionality. Signed-off-by: Eric Deandrea <[email protected]>
1 parent f184ee7 commit e47f3f9

File tree

14 files changed

+246
-36
lines changed

14 files changed

+246
-36
lines changed

README.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ This project provides the following artifacts:
4545

4646
## Getting started
4747

48-
Use `DoclingServeApi.convertSource()` to convert individual documents. For example:
48+
Use `DoclingServeApi.convertSource()` to convert individual documents (make sure both `docling-serve-api` and `docling-serve-client` are on your classpath).
49+
50+
For example:
4951

5052
```java
5153
import ai.docling.serve.api.DoclingServeApi;
5254
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
5355
import ai.docling.serve.api.convert.request.source.HttpSource;
5456
import ai.docling.serve.api.convert.response.ConvertDocumentResponse;
55-
import ai.docling.serve.client.DoclingServeClientBuilderFactory;
5657

57-
DoclingServeApi doclingServeApi = DoclingServeClientBuilderFactory.newBuilder()
58+
DoclingServeApi doclingServeApi = DoclingServeApi.builder()
5859
.baseUrl("<location of docling serve instance>")
5960
.build();
6061

@@ -124,5 +125,3 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
124125
<!-- ALL-CONTRIBUTORS-LIST:END -->
125126

126127
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind are welcome!
127-
128-
### IBM ❤️ Open Source AI

docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeApi.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,52 @@
11
package ai.docling.serve.api;
22

3+
import static ai.docling.serve.api.util.ValidationUtils.ensureNotBlank;
4+
5+
import java.net.URI;
36
import java.time.Duration;
7+
import java.util.stream.Collectors;
48

59
import org.jspecify.annotations.Nullable;
610

711
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
12+
import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
13+
import ai.docling.serve.api.spi.ServiceLoaderHelper;
814

915
/**
1016
* Docling Serve API interface.
1117
*/
1218
public interface DoclingServeApi
1319
extends DoclingServeHealthApi, DoclingServeConvertApi, DoclingServeChunkApi, DoclingServeClearApi, DoclingServeTaskApi {
1420

21+
/**
22+
* Creates and returns a builder instance capable of constructing implementations of {@link DoclingServeApi}.
23+
* The method ensures that exactly one factory capable of building a builder instance is available
24+
* via the {@link DoclingServeApiBuilderFactory} interface.
25+
*
26+
* If no factories are found, or if multiple factories are found, an {@link IllegalStateException} is thrown.
27+
*
28+
* @param <T> the type of the {@link DoclingServeApi} implementation being built
29+
* @param <B> the type of the builder implementation for the {@link DoclingServeApi}
30+
* @return a builder instance of type {@code B} constructed using the available factory
31+
* @throws IllegalStateException if no factories or more than one factory are found
32+
*/
33+
static <T extends DoclingServeApi, B extends DoclingApiBuilder<T, B>> B builder() {
34+
var factories = ServiceLoaderHelper.loadFactories(DoclingServeApiBuilderFactory.class);
35+
36+
if (factories.isEmpty()) {
37+
// No factory found
38+
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()));
39+
}
40+
41+
if (factories.size() > 1) {
42+
// Multiple factories found
43+
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(", "))));
44+
}
45+
46+
// Only 1 factory (what we want)
47+
return factories.iterator().next().getBuilder();
48+
}
49+
1550
/**
1651
* Creates and returns a builder instance capable of constructing a duplicate or modified
1752
* version of the current API instance. The builder provides a customizable way to adjust
@@ -30,6 +65,31 @@ public interface DoclingServeApi
3065
* @param <B> the type of the concrete builder implementation.
3166
*/
3267
interface DoclingApiBuilder<T extends DoclingServeApi, B extends DoclingApiBuilder<T, B>> {
68+
/**
69+
* Sets the base URL for the client.
70+
*
71+
* <p>This method configures the base URL that will be used for all API requests
72+
* executed by the client. The provided URL must be non-null and not blank.
73+
*
74+
* @param baseUrl the base URL to use, as a {@code String}
75+
* @return this builder instance for method chaining
76+
* @throws IllegalArgumentException if {@code baseUrl} is null, blank, or invalid
77+
*/
78+
default B baseUrl(String baseUrl) {
79+
return baseUrl(URI.create(ensureNotBlank(baseUrl, "baseUrl")));
80+
}
81+
82+
/**
83+
* Sets the base URL for the client.
84+
*
85+
* <p>This method configures the base URL that will be used for all API requests
86+
* executed by the client. The provided URL must be non-null.
87+
*
88+
* @param baseUrl the base URL to use, as a {@link URI}
89+
* @return this builder instance for method chaining
90+
*/
91+
B baseUrl(URI baseUrl);
92+
3393
/**
3494
* Sets the API key for authenticating requests made by the client being built.
3595
*
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package ai.docling.serve.api.spi;
2+
3+
import ai.docling.serve.api.DoclingServeApi;
4+
import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
5+
6+
/**
7+
* Factory interface for creating builder instances to construct implementations of {@link DoclingServeApi}.
8+
*/
9+
public interface DoclingServeApiBuilderFactory {
10+
/**
11+
* Retrieves a builder instance for constructing implementations of {@link DoclingServeApi}.
12+
* The returned builder allows customization of various configurations and properties
13+
* before creating an instance of the API.
14+
*
15+
* @param <T> the type of the {@link DoclingServeApi} implementation being built
16+
* @param <B> the type of the concrete builder implementation
17+
* @return a builder of type {@code B}, which is used to construct instances of {@code T}
18+
*/
19+
<T extends DoclingServeApi, B extends DoclingApiBuilder<T, B>> B getBuilder();
20+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package ai.docling.serve.api.spi;
2+
3+
import java.util.ArrayList;
4+
import java.util.Collection;
5+
import java.util.List;
6+
import java.util.Objects;
7+
import java.util.Optional;
8+
import java.util.ServiceLoader;
9+
10+
import org.jspecify.annotations.Nullable;
11+
12+
/**
13+
* Utility wrapper around {@code ServiceLoader.load()}.
14+
*/
15+
public final class ServiceLoaderHelper {
16+
private ServiceLoaderHelper() {
17+
}
18+
19+
/**
20+
* Load the first available service of a given type.
21+
*
22+
* @param clazz the type of service
23+
* @param <T> the type of service
24+
* @return the first service, or {@link Optional#empty()} if none
25+
*/
26+
public static <T> Optional<T> loadFactory(Class<T> clazz) {
27+
var factories = loadFactories(clazz, null);
28+
return factories.isEmpty() ? Optional.empty() : Optional.ofNullable(factories.iterator().next());
29+
}
30+
31+
/**
32+
* Load all the services of a given type.
33+
*
34+
* @param clazz the type of service
35+
* @param <T> the type of service
36+
* @return the list of services, empty if none
37+
*/
38+
public static <T> Collection<T> loadFactories(Class<T> clazz) {
39+
return loadFactories(clazz, null);
40+
}
41+
42+
/**
43+
* Load all the services of a given type.
44+
*
45+
* <p>Utility mechanism around {@code ServiceLoader.load()}</p>
46+
*
47+
* <ul>
48+
* <li>If classloader is {@code null}, will try {@code ServiceLoader.load(clazz)}</li>
49+
* <li>If classloader is not {@code null}, will try {@code ServiceLoader.load(clazz, classloader)}</li>
50+
* </ul>
51+
*
52+
* <p>If the above return nothing, will fall back to {@code ServiceLoader.load(clazz, $this class loader$)}</p>
53+
*
54+
* @param clazz the type of service
55+
* @param classLoader the classloader to use, may be null
56+
* @param <T> the type of service
57+
* @return the list of services, empty if none
58+
*/
59+
public static <T> Collection<T> loadFactories(Class<T> clazz, @Nullable ClassLoader classLoader) {
60+
var factories = Optional.ofNullable(classLoader)
61+
.map(cl -> loadAll(ServiceLoader.load(clazz, cl)))
62+
.orElseGet(() -> loadAll(ServiceLoader.load(clazz)));
63+
64+
// By default, ServiceLoader.load uses the TCCL, this may not be enough in environment dealing with
65+
// classloaders differently such as OSGi. So we should try to use the classloader having loaded this
66+
// class. In OSGi it would be the bundle exposing vert.x and so have access to all its classes.
67+
var factoriesToReturn = factories.isEmpty() ?
68+
loadAll(ServiceLoader.load(clazz, ServiceLoaderHelper.class.getClassLoader())) :
69+
factories;
70+
71+
return factoriesToReturn.stream()
72+
.filter(Objects::nonNull)
73+
.toList();
74+
}
75+
76+
/**
77+
* Load all the services from a ServiceLoader.
78+
*
79+
* @param loader the loader
80+
* @param <T> the type of service
81+
* @return the list of services, empty if none
82+
*/
83+
private static <T> List<T> loadAll(ServiceLoader<T> loader) {
84+
List<T> list = new ArrayList<>();
85+
loader.iterator().forEachRemaining(list::add);
86+
87+
return list;
88+
}
89+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
@NullMarked
2+
package ai.docling.serve.api.spi;
3+
4+
import org.jspecify.annotations.NullMarked;

docling-serve/docling-serve-api/src/main/java/module-info.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,8 @@
3939

4040
// Validation API
4141
exports ai.docling.serve.api.validation;
42+
43+
// SPI
44+
exports ai.docling.serve.api.spi;
45+
uses ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
4246
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ai.docling.serve.api;
2+
3+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
8+
import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
9+
10+
class DoclingServeApiTests {
11+
@Test
12+
void noFactoryFound() {
13+
assertThatExceptionOfType(IllegalStateException.class)
14+
.isThrownBy(() -> DoclingServeApi.builder())
15+
.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());
16+
}
17+
}

docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClient.java

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package ai.docling.serve.client;
22

3-
import static ai.docling.serve.api.util.ValidationUtils.ensureNotBlank;
43
import static ai.docling.serve.api.util.ValidationUtils.ensureNotNull;
54

65
import java.io.IOException;
@@ -398,21 +397,6 @@ protected DoclingServeClientBuilder(DoclingServeClient doclingClient) {
398397
this.apiKey = doclingClient.apiKey;
399398
}
400399

401-
/**
402-
* Sets the base URL for the client.
403-
*
404-
* <p>This method configures the base URL that will be used for all API requests
405-
* executed by the client. The provided URL must be non-null and not blank.
406-
*
407-
* @param baseUrl the base URL to use, as a {@code String}
408-
* @return this builder instance for method chaining
409-
* @throws IllegalArgumentException if {@code baseUrl} is null, blank, or invalid
410-
*/
411-
public B baseUrl(String baseUrl) {
412-
this.baseUrl = URI.create(ensureNotBlank(baseUrl, "baseUrl"));
413-
return (B) this;
414-
}
415-
416400
/**
417401
* Sets the base URL for the client.
418402
*
@@ -423,6 +407,7 @@ public B baseUrl(String baseUrl) {
423407
* @return this builder instance for method chaining
424408
* @throws IllegalArgumentException if {@code baseUrl} is null
425409
*/
410+
@Override
426411
public B baseUrl(URI baseUrl) {
427412
this.baseUrl = baseUrl;
428413
return (B) this;

docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/DoclingServeClientBuilderFactory.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package ai.docling.serve.client;
22

3+
import ai.docling.serve.api.DoclingServeApi;
4+
import ai.docling.serve.api.DoclingServeApi.DoclingApiBuilder;
5+
import ai.docling.serve.api.spi.DoclingServeApiBuilderFactory;
36
import ai.docling.serve.client.DoclingServeClient.DoclingServeClientBuilder;
47

58
/**
@@ -15,10 +18,7 @@
1518
* <p>The factory uses a type-safe generic method to support custom subclasses of
1619
* {@link DoclingServeClient} and {@link DoclingServeClientBuilder}.
1720
*/
18-
public final class DoclingServeClientBuilderFactory {
19-
private DoclingServeClientBuilderFactory() {
20-
}
21-
21+
public final class DoclingServeClientBuilderFactory implements DoclingServeApiBuilderFactory {
2222
/**
2323
* Creates and returns a new instance of a {@link DoclingServeClientBuilder} compatible
2424
* with the Jackson version present on the provided classloader's classpath.
@@ -70,6 +70,11 @@ public static <C extends DoclingServeClient, B extends DoclingServeClientBuilder
7070
return newBuilder(Thread.currentThread().getContextClassLoader());
7171
}
7272

73+
@Override
74+
public <T extends DoclingServeApi, B extends DoclingApiBuilder<T, B>> B getBuilder() {
75+
return (B) newBuilder();
76+
}
77+
7378
private enum JacksonVersion {
7479
JACKSON_2("com.fasterxml.jackson.databind.json.JsonMapper"),
7580
JACKSON_3("tools.jackson.databind.json.JsonMapper");

docling-serve/docling-serve-client/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@
99
requires java.net.http;
1010

1111
exports ai.docling.serve.client;
12+
provides ai.docling.serve.api.spi.DoclingServeApiBuilderFactory with ai.docling.serve.client.DoclingServeClientBuilderFactory;
1213
}

0 commit comments

Comments
 (0)