diff --git a/api/build.gradle.kts b/api/build.gradle.kts index b587440..20275c0 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -6,5 +6,6 @@ dependencies { implementation(platform(libs.jackson.bom)) implementation("com.fasterxml.jackson.core:jackson-annotations") + implementation("tools.jackson.core:jackson-databind") testImplementation(project(":docling-testcontainers")) } diff --git a/api/src/main/java/ai/docling/api/DoclingApi.java b/api/src/main/java/ai/docling/api/DoclingApi.java index bdf3cc8..55be038 100644 --- a/api/src/main/java/ai/docling/api/DoclingApi.java +++ b/api/src/main/java/ai/docling/api/DoclingApi.java @@ -9,8 +9,44 @@ */ public interface DoclingApi { + /** + * Executes a health check for the API and retrieves the health status of the service. + * + * @return a {@link HealthCheckResponse} object containing the health status of the API. + */ HealthCheckResponse health(); + /** + * Converts the provided document source(s) into a processed document based on the specified options. + * + * @param request the {@link ConvertDocumentRequest} containing the source(s), conversion options, and optional target. + * @return a {@link ConvertDocumentResponse} containing the processed document data, processing details, and any errors. + */ ConvertDocumentResponse convertSource(ConvertDocumentRequest request); + /** + * 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. + * + * @return a {@link DoclingApiBuilder} initialized with the state of the current API instance. + */ + > DoclingApiBuilder toBuilder(); + + /** + * A builder interface for constructing implementations of {@link DoclingApi}. This interface + * supports a fluent API for setting configuration properties before building an instance. + * + * @param the type of the {@link DoclingApi} implementation being built. + * @param the type of the concrete builder implementation. + */ + interface DoclingApiBuilder> { + /** + * 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. + * + * @return an instance of type {@code T} representing a configured Docling API client. + */ + T build(); + } } diff --git a/api/src/main/java/ai/docling/api/convert/response/DocumentResponse.java b/api/src/main/java/ai/docling/api/convert/response/DocumentResponse.java index 008c9c9..8351b0b 100644 --- a/api/src/main/java/ai/docling/api/convert/response/DocumentResponse.java +++ b/api/src/main/java/ai/docling/api/convert/response/DocumentResponse.java @@ -2,33 +2,269 @@ import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.jspecify.annotations.Nullable; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -@JsonInclude(JsonInclude.Include.NON_NULL) -public record DocumentResponse( +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonPOJOBuilder; - @JsonProperty("doctags_content") @Nullable String doctagsContent, +@JsonInclude(JsonInclude.Include.NON_ABSENT) +@JsonDeserialize(builder = DocumentResponse.Builder.class) +public interface DocumentResponse { + /** + * Retrieves the content of the doc tags, if available. + * + * @return the content of the doc tags, or null if not present + */ + @Nullable + String doctagsContent(); - @JsonProperty("filename") String filename, + /** + * Retrieves the filename associated with the document. + * + * @return the filename of the document as a string + */ + String filename(); - @JsonProperty("html_content") @Nullable String htmlContent, + /** + * Retrieves the HTML content associated with the document, if available. + * + * @return the HTML content as a string, or null if not present + */ + @Nullable + String htmlContent(); - @JsonProperty("json_content") @Nullable Map jsonContent, + /** + * Retrieves the JSON content associated with the document. + * + * @return a map representing the JSON content, or an empty map if no JSON content is present + */ + Map jsonContent(); - @JsonProperty("md_content") @Nullable String markdownContent, + /** + * Retrieves the Markdown content associated with the document, if available. + * + * @return the Markdown content as a string, or null if no Markdown content is present + */ + @Nullable + String markdownContent(); - @JsonProperty("text_content") @Nullable String textContent + /** + * Retrieves the plain text content associated with the document, if available. + * + * @return the plain text content as a string, or null if no text content is present + */ + @Nullable + String textContent(); -) { + /** + * Creates a new {@code Builder} instance initialized with the current state of the {@code DocumentResponse}. + * + * @return a {@code Builder} instance populated with the values from this {@code DocumentResponse} + */ + default Builder toBuilder() { + return new Builder(this); + } + + /** + * Creates and returns a new instance of the {@code Builder} class, which can be used to + * construct a {@code DocumentResponse} object in a step-by-step manner. + * + * @return a new {@code Builder} instance + */ + static Builder builder() { + return new Builder(); + } + + /** + * Default implementation of the {@link DocumentResponse} interface. + * This record represents the response containing document data in various formats. + * It is an immutable data structure that consolidates information related to a document, + * such as its filename, content in multiple formats, and metadata. + * + * Each instance ensures the provided JSON content is unmodifiable by copying + * the input map if it is present, or initializing it to an empty map otherwise. + */ + record DefaultDocumentResponse(String doctagsContent, + String filename, + String htmlContent, + Map jsonContent, + String markdownContent, + String textContent) implements DocumentResponse { - public DocumentResponse { - if (jsonContent != null) { - jsonContent = new HashMap<>(jsonContent); + public DefaultDocumentResponse { + jsonContent = Optional.ofNullable(jsonContent) + .map(Map::copyOf) + .orElseGet(Map::of); + } + + public DefaultDocumentResponse(Builder builder) { + this(builder.doctagsContent, + builder.filename, + builder.htmlContent, + builder.jsonContent, + builder.markdownContent, + builder.textContent); } } + /** + * A builder class for constructing instances of {@code DocumentResponse}. + * + * This class provides a step-by-step approach to configure and create a + * {@code DocumentResponse} object. Each method in this class sets a specific + * property of the object being built. Once all the desired properties are set, + * the {@code build} method is used to create the final {@code DocumentResponse} + * instance. + * + * The builder supports customization of various document-related attributes, + * including doc tags content, filename, HTML content, JSON content, Markdown + * content, and plain text content. + * + * By default, the builder initializes attributes with an empty state or default + * values. If a {@code DocumentResponse} instance is provided to the constructor, + * the builder is pre-populated with the attributes from the given response. + * + * This class is intended for internal use and is protected to restrict its + * accessibility outside the defining package or class hierarchy. + */ + @JsonPOJOBuilder(withPrefix = "") + class Builder { + protected String doctagsContent; + protected String filename; + protected String htmlContent; + protected Map jsonContent = new HashMap<>(); + protected String markdownContent; + protected String textContent; + + /** + * Constructs a new {@code Builder} instance. + * + * This constructor initializes a builder with default or empty states for all + * attributes. It is protected to restrict direct instantiation outside of the + * defining package or class hierarchy. + * + * The {@code Builder} class is primarily used to facilitate the creation of + * {@code DocumentResponse} objects through a step-by-step configuration process. + */ + protected Builder() { + + } + + /** + * Constructs a new {@code Builder} instance using the provided {@code DocumentResponse}. + * + * This constructor initializes the builder's fields with the data from the given + * {@code DocumentResponse} object. It allows for the creation of a {@code Builder} + * instance pre-populated with the state of an existing {@code DocumentResponse}. + * + * @param documentResponse the {@code DocumentResponse} instance whose data will + * populate the fields of this builder + */ + protected Builder(DocumentResponse documentResponse) { + this.doctagsContent = documentResponse.doctagsContent(); + this.filename = documentResponse.filename(); + this.htmlContent = documentResponse.htmlContent(); + this.jsonContent = documentResponse.jsonContent(); + this.markdownContent = documentResponse.markdownContent(); + this.textContent = documentResponse.textContent(); + } + + /** + * Sets the doctags content for the builder instance. + * + * @param doctagsContent the doctags content to be set + * @return this Builder instance for method chaining + */ + @JsonProperty("doctags_content") + public Builder doctagsContent(String doctagsContent) { + this.doctagsContent = doctagsContent; + return this; + } + + /** + * Sets the filename for the builder instance. + * + * @param filename the filename to be set + * @return this Builder instance for method chaining + */ + @JsonProperty("filename") + public Builder filename(String filename) { + this.filename = filename; + return this; + } + + /** + * Sets the HTML content for the builder instance. + * + * @param htmlContent the HTML content to be set + * @return this Builder instance for method chaining + */ + @JsonProperty("html_content") + public Builder htmlContent(String htmlContent) { + this.htmlContent = htmlContent; + return this; + } + + /** + * Sets the JSON content for the builder instance. + * + * The JSON content is represented as a map of key-value pairs, where the keys + * are {@code String} objects, and the values are {@code Object} instances. + * + * @param jsonContent the JSON content to be set, represented as a {@code Map} + * @return this {@link Builder} instance for method chaining + */ + @JsonProperty("json_content") + public Builder jsonContent(Map jsonContent) { + this.jsonContent = jsonContent; + return this; + } + + /** + * Sets the Markdown content for this builder instance. + * + * The Markdown content represents the textual data formatted in Markdown syntax, + * which can include headings, lists, links, and other Markdown elements. + * + * @param markdownContent the Markdown content to be set, represented as a {@code String} + * @return this {@link Builder} instance for method chaining + */ + @JsonProperty("md_content") + public Builder markdownContent(String markdownContent) { + this.markdownContent = markdownContent; + return this; + } + + /** + * Sets the plain text content for this builder instance. + * + * The plain text content represents unformatted textual data that can be + * used for display or processing purposes within the application. + * + * @param textContent the plain text content to be set, represented as a {@code String} + * @return this {@link Builder} instance for method chaining + */ + @JsonProperty("text_content") + public Builder textContent(String textContent) { + this.textContent = textContent; + return this; + } + + /** + * Creates and returns a {@link DocumentResponse} instance based on the current state of this {@link Builder}. + * + *

The returned {@link DocumentResponse} will encapsulate the values configured in the builder, + * and further modifications to the builder instance will not affect the created {@code DocumentResponse}. + * + * @return a new {@code DocumentResponse} instance constructed from the builder's state + */ + public DocumentResponse build() { + return new DefaultDocumentResponse(this); + } + } } diff --git a/api/src/main/java/ai/docling/api/util/Utils.java b/api/src/main/java/ai/docling/api/util/Utils.java new file mode 100644 index 0000000..1b8a436 --- /dev/null +++ b/api/src/main/java/ai/docling/api/util/Utils.java @@ -0,0 +1,168 @@ +package ai.docling.api.util; + +import static ai.docling.api.util.ValidationUtils.ensureNotEmpty; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Utility methods. + */ +public final class Utils { + private Utils() {} + + /** + * Returns the first non-null value from the provided array of values. + * If all values are null, an new IllegalArgumentException is thrown. + * + * @param name A non-null string representing the name associated with the values. + * @param values An array of potentially nullable values to search through. + * @return The first non-null value in the array. + * @throws IllegalArgumentException If all values are null or if the array is empty. + */ + @SafeVarargs + @NonNull + public static T firstNotNull(@NonNull String name, @Nullable T... values) { + ensureNotEmpty(values, name + " values"); + + return Stream.of(values) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("At least one of the given '%s' values must be not null".formatted(name))); + } + + /** + * Returns the given value if it is not {@code null}, otherwise returns the given default value. + * + * @param value The value to return if it is not {@code null}. + * @param defaultValue The value to return if the value is {@code null}. + * @param The type of the value. + * @return the given value if it is not {@code null}, otherwise returns the given default value. + */ + public static T getOrDefault(T value, T defaultValue) { + return Optional.ofNullable(value).orElse(defaultValue); + } + + /** + * Returns the given list if it is not {@code null} and not empty, otherwise returns the given default list. + * + * @param list The list to return if it is not {@code null} and not empty. + * @param defaultList The list to return if the list is {@code null} or empty. + * @param The type of the value. + * @return the given list if it is not {@code null} and not empty, otherwise returns the given default list. + */ + public static List getOrDefault(List list, List defaultList) { + return isNullOrEmpty(list) ? defaultList : list; + } + + /** + * Returns the given map if it is not {@code null} and not empty, otherwise returns the given default map. + * + * @param map The map to return if it is not {@code null} and not empty. + * @param defaultMap The map to return if the map is {@code null} or empty. + * @return the given map if it is not {@code null} and not empty, otherwise returns the given default map. + */ + public static Map getOrDefault(Map map, Map defaultMap) { + return isNullOrEmpty(map) ? defaultMap : map; + } + + /** + * Returns the given value if it is not {@code null}, otherwise returns the value returned by the given supplier. + * + * @param value The value to return if it is not {@code null}. + * @param defaultValueSupplier The supplier to call if the value is {@code null}. + * @param The type of the value. + * @return the given value if it is not {@code null}, otherwise returns the value returned by the given supplier. + */ + public static T getOrDefault(@Nullable T value, Supplier defaultValueSupplier) { + return Optional.ofNullable(value).orElseGet(defaultValueSupplier::get); + } + + /** + * Is the given string {@code null} or blank? + * + * @param string The string to check. + * @return true if the string is {@code null} or blank. + */ + public static boolean isNullOrBlank(String string) { + return string == null || string.trim().isBlank(); + } + + /** + * Is the given string {@code null} or empty ("")? + * + * @param string The string to check. + * @return true if the string is {@code null} or empty. + */ + public static boolean isNullOrEmpty(String string) { + return string == null || string.isEmpty(); + } + + /** + * Is the given string not {@code null} and not blank? + * + * @param string The string to check. + * @return true if there's something in the string. + */ + public static boolean isNotNullOrBlank(String string) { + return !isNullOrBlank(string); + } + + /** + * Is the given string not {@code null} and not empty ("")? + * + * @param string The string to check. + * @return true if the given string is not {@code null} and not empty ("")? + */ + public static boolean isNotNullOrEmpty(String string) { + return !isNullOrEmpty(string); + } + + /** + * Is the collection {@code null} or empty? + * + * @param collection The collection to check. + * @return {@code true} if the collection is {@code null} or {@link Collection#isEmpty()}, otherwise {@code false}. + */ + public static boolean isNullOrEmpty(Collection collection) { + return (collection == null) || collection.isEmpty(); + } + + /** + * Is the iterable object {@code null} or empty? + * + * @param iterable The iterable object to check. + * @return {@code true} if the iterable object is {@code null} or there are no objects to iterate over, otherwise {@code false}. + */ + public static boolean isNullOrEmpty(Iterable iterable) { + return (iterable == null) || !iterable.iterator().hasNext(); + } + + /** + * Utility method to check if an array is null or has no elements. + * + * @param array the array to check + * @return {@code true} if the array is null or has no elements, otherwise {@code false} + */ + public static boolean isNullOrEmpty(T[] array) { + return (array == null) || (array.length == 0); + } + + /** + * Is the map object {@code null} or empty? + * + * @param map The iterable object to check. + * @return {@code true} if the map object is {@code null} or empty map, otherwise {@code false}. + */ + public static boolean isNullOrEmpty(@Nullable Map map) { + return (map == null) || map.isEmpty(); + } +} diff --git a/api/src/main/java/ai/docling/api/util/ValidationUtils.java b/api/src/main/java/ai/docling/api/util/ValidationUtils.java new file mode 100644 index 0000000..bcaec31 --- /dev/null +++ b/api/src/main/java/ai/docling/api/util/ValidationUtils.java @@ -0,0 +1,274 @@ +package ai.docling.api.util; + +import static ai.docling.api.util.Utils.isNullOrBlank; +import static ai.docling.api.util.Utils.isNullOrEmpty; + +import java.util.Collection; +import java.util.Map; +import java.util.Objects; + +/** + * Utility class for validating method arguments. + */ +public final class ValidationUtils { + private ValidationUtils() {} + + /** + * Ensure that the two values are equal. + * @param lhs the left hand side value. + * @param rhs the right hand side value. + * @param format the format string for the exception message. + * @param args the format arguments for the exception message. + */ + public static void ensureEq(Object lhs, Object rhs, String format, Object... args) { + if (!Objects.equals(lhs, rhs)) { + throw new IllegalArgumentException(format.formatted(args)); + } + } + + /** + * Ensures that the given object is not null. + * @param object The object to check. + * @param name The name of the object to be used in the exception message. + * @return The object if it is not null. + * @param The type of the object. + * @throws IllegalArgumentException if the object is null. + */ + public static T ensureNotNull(T object, String name) { + return ensureNotNull(object, "%s cannot be null", name); + } + + /** + * Ensures that the given object is not null. + * @param object The object to check. + * @param format The format of the exception message. + * @param args The arguments for the exception message. + * @return The object if it is not null. + * @param The type of the object. + */ + public static T ensureNotNull(T object, String format, Object... args) { + if (object == null) { + throw new IllegalArgumentException(format.formatted(args)); + } + return object; + } + + /** + * Ensures that the given collection is not null and not empty. + * @param collection The collection to check. + * @param name The name of the collection to be used in the exception message. + * @return The collection if it is not null and not empty. + * @param The type of the collection. + * @throws IllegalArgumentException if the collection is null or empty. + */ + public static Collection ensureNotEmpty(Collection collection, String name) { + if (isNullOrEmpty(collection)) { + throw new IllegalArgumentException("%s cannot be null or empty".formatted(name)); + } + + return collection; + } + + /** + * Ensures that the given array is not null and not empty. + * @param array The array to check. + * @param name The name of the array to be used in the exception message. + * @return The array if it is not null and not empty. + * @param The component type of the array. + * @throws IllegalArgumentException if the array is null or empty. + */ + public static T[] ensureNotEmpty(T[] array, String name) { + return ensureNotEmpty(array, "%s cannot be null or empty", name); + } + + /** + * Ensures that the given array is not null and not empty. + * @param array The array to check. + * @param format The format of the exception message. + * @param args The arguments for the exception message. + * @return The array if it is not null and not empty. + * @param The component type of the array. + * @throws IllegalArgumentException if the array is null or empty. + */ + public static T[] ensureNotEmpty(T[] array, String format, Object... args) { + if (array == null || array.length == 0) { + throw new IllegalArgumentException(format.formatted(args)); + } + return array; + } + + /** + * Ensures that the given map is not null and not empty. + * + * @param map The map to check. + * @param name The name of the map to be used in the exception message. + * @param The type of the key. + * @param The type of the value. + * @return The map if it is not null and not empty. + * @throws IllegalArgumentException if the collection is null or empty. + */ + public static Map ensureNotEmpty(Map map, String name) { + if (isNullOrEmpty(map)) { + throw new IllegalArgumentException("%s cannot be null or empty".formatted(name)); + } + + return map; + } + + /** + * Ensures that the given string is not null and not empty. + * @param string The string to check. + * @param name The name of the string to be used in the exception message. + * @return The string if it is not null and not empty. + * @throws IllegalArgumentException if the string is null or empty. + */ + public static String ensureNotEmpty(String string, String name) { + return ensureNotEmpty(string, "%s cannot be null or empty", name); + } + + /** + * Ensures that the given string is not null and not empty. + * @param string The string to check. + * @param format The format of the exception message. + * @param args The arguments for the exception message. + * @return The string if it is not null and not empty. + * @throws IllegalArgumentException if the string is null or empty. + */ + public static String ensureNotEmpty(String string, String format, Object... args) { + if (isNullOrEmpty(string)) { + throw new IllegalArgumentException(format.formatted(args)); + } + return string; + } + + /** + * Ensures that the given string is not null and not blank. + * @param string The string to check. + * @param name The name of the string to be used in the exception message. + * @return The string if it is not null and not blank. + * @throws IllegalArgumentException if the string is null or blank. + */ + public static String ensureNotBlank(String string, String name) { + return ensureNotBlank(string, "%s cannot be null or blank", name); + } + + /** + * Ensures that the given string is not null and not blank. + * @param string The string to check. + * @param format The format of the exception message. + * @param args The arguments for the exception message. + * @return The string if it is not null and not blank. + * @throws IllegalArgumentException if the string is null or blank. + */ + public static String ensureNotBlank(String string, String format, Object... args) { + if (isNullOrBlank(string)) { + throw new IllegalArgumentException(format.formatted(args)); + } + return string; + } + + /** + * Ensures that the given expression is true. + * @param expression The expression to check. + * @param msg The message to be used in the exception. + * @throws IllegalArgumentException if the expression is false. + */ + public static void ensureTrue(boolean expression, String msg) { + if (!expression) { + throw new IllegalArgumentException(msg); + } + } + + /** + * Ensures that the given integer is not negative. + * @param i The integer to check. + * @param name The logical name of the integer, to be used in the exception. + * @return The value if it is not negative. + * @throws IllegalArgumentException if the integer is negative. + */ + public static int ensureNotNegative(Integer i, String name) { + if (i == null || i < 0) { + throw new IllegalArgumentException("%s must not be negative, but is: %s".formatted(name, i)); + } + + return i; + } + + /** + * Ensures that the given expression is true. + * @param i The expression to check. + * @param name The message to be used in the exception. + * @return The value if it is greater than zero. + * @throws IllegalArgumentException if the expression is false. + */ + public static int ensureGreaterThanZero(Integer i, String name) { + if (i == null || i <= 0) { + throw new IllegalArgumentException("%s must be greater than zero, but is: %s".formatted(name, i)); + } + + return i; + } + + /** + * Ensures that the given expression is true. + * @param i The expression to check. + * @param name The message to be used in the exception. + * @return The value if it is greater than zero. + * @throws IllegalArgumentException if the expression is false. + */ + public static double ensureGreaterThanZero(Double i, String name) { + if (i == null || i <= 0) { + throw new IllegalArgumentException("%s must be greater than zero, but is: %s".formatted(name, i)); + } + + return i; + } + + /** + * Ensures that the given Double value is in {@code [min, max]}. + * @param d The value to check. + * @param min The minimum value. + * @param max The maximum value. + * @param name The value name to be used in the exception. + * @return The value if it is in {@code [min, max]}. + * @throws IllegalArgumentException if the value is not in {@code [min, max]}. + */ + public static double ensureBetween(Double d, double min, double max, String name) { + if (d == null || d < min || d > max) { + throw new IllegalArgumentException("%s must be between %s and %s, but is: %s".formatted(name, min, max, d)); + } + return d; + } + + /** + * Ensures that the given Integer value is in {@code [min, max]}. + * @param i The value to check. + * @param min The minimum value. + * @param max The maximum value. + * @param name The value name to be used in the exception. + * @return The value if it is in {@code [min, max]}. + * @throws IllegalArgumentException if the value is not in {@code [min, max]}. + */ + public static int ensureBetween(Integer i, int min, int max, String name) { + if (i == null || i < min || i > max) { + throw new IllegalArgumentException("%s must be between %s and %s, but is: %s".formatted(name, min, max, i)); + } + return i; + } + + /** + * Ensures that the given Long value is in {@code [min, max]}. + * @param i The value to check. + * @param min The minimum value. + * @param max The maximum value. + * @param name The value name to be used in the exception. + * @return The value if it is in {@code [min, max]}. + * @throws IllegalArgumentException if the value is not in {@code [min, max]}. + */ + public static long ensureBetween(Long i, long min, long max, String name) { + if (i == null || i < min || i > max) { + throw new IllegalArgumentException("%s must be between %s and %s, but is: %s".formatted( name, min, max, i)); + } + return i; + } +} diff --git a/api/src/test/java/ai/docling/api/convert/response/ConvertDocumentResponseTests.java b/api/src/test/java/ai/docling/api/convert/response/ConvertDocumentResponseTests.java index 71d9725..6464dc1 100644 --- a/api/src/test/java/ai/docling/api/convert/response/ConvertDocumentResponseTests.java +++ b/api/src/test/java/ai/docling/api/convert/response/ConvertDocumentResponseTests.java @@ -16,14 +16,14 @@ class ConvertDocumentResponseTests { @Test void createResponseWithAllFields() { - DocumentResponse document = new DocumentResponse( - "doctags content", - "test-file.pdf", - "content", - Map.of("key", "value"), - "# Markdown content", - "Plain text content" - ); + DocumentResponse document = DocumentResponse.builder() + .doctagsContent("doctags content") + .filename("test-file.pdf") + .htmlContent("content") + .jsonContent(Map.of("key", "value")) + .markdownContent("# Markdown content") + .textContent("Plain text content") + .build(); List errors = List.of( new ErrorItem("parser", "Parse error", "pdf_module"), @@ -71,14 +71,11 @@ void createResponseWithNullFields() { @Test void createResponseWithEmptyCollections() { - DocumentResponse document = new DocumentResponse( - null, - "empty-file.txt", - null, - Map.of(), - null, - "" - ); + DocumentResponse document = DocumentResponse.builder() + .filename("empty-file.txt") + .jsonContent(Map.of()) + .textContent("") + .build(); List errors = List.of(); Map timings = Map.of(); diff --git a/api/src/test/java/ai/docling/api/convert/response/DocumentResponseTests.java b/api/src/test/java/ai/docling/api/convert/response/DocumentResponseTests.java index c39d0a2..1642d2a 100644 --- a/api/src/test/java/ai/docling/api/convert/response/DocumentResponseTests.java +++ b/api/src/test/java/ai/docling/api/convert/response/DocumentResponseTests.java @@ -11,7 +11,6 @@ * Unit tests for {@link DocumentResponse}. */ class DocumentResponseTests { - @Test void createResponseWithAllFields() { String doctagsContent = "doctags content"; @@ -25,14 +24,14 @@ void createResponseWithAllFields() { String markdownContent = "# Test Document\n\nThis is a test document."; String textContent = "Test Document\n\nThis is a test document."; - DocumentResponse response = new DocumentResponse( - doctagsContent, - filename, - htmlContent, - jsonContent, - markdownContent, - textContent - ); + DocumentResponse response = DocumentResponse.builder() + .doctagsContent(doctagsContent) + .filename(filename) + .htmlContent(htmlContent) + .jsonContent(jsonContent) + .markdownContent(markdownContent) + .textContent(textContent) + .build(); assertThat(response.doctagsContent()).isEqualTo(doctagsContent); assertThat(response.filename()).isEqualTo(filename); @@ -44,19 +43,12 @@ void createResponseWithAllFields() { @Test void createResponseWithNullFields() { - DocumentResponse response = new DocumentResponse( - null, - null, - null, - null, - null, - null - ); + DocumentResponse response = DocumentResponse.builder().build(); assertThat(response.doctagsContent()).isNull(); assertThat(response.filename()).isNull(); assertThat(response.htmlContent()).isNull(); - assertThat(response.jsonContent()).isNull(); + assertThat(response.jsonContent()).isNotNull().isEmpty(); assertThat(response.markdownContent()).isNull(); assertThat(response.textContent()).isNull(); } @@ -68,14 +60,12 @@ void createResponseWithEmptyFields() { String markdownContent = ""; String textContent = ""; - DocumentResponse response = new DocumentResponse( - null, - filename, - null, - jsonContent, - markdownContent, - textContent - ); + DocumentResponse response = DocumentResponse.builder() + .filename(filename) + .jsonContent(jsonContent) + .markdownContent(markdownContent) + .textContent(textContent) + .build(); assertThat(response.doctagsContent()).isNull(); assertThat(response.filename()).isEqualTo(filename); @@ -92,14 +82,9 @@ void documentResponseIsImmutable() { "count", 1 )); - DocumentResponse response = new DocumentResponse( - null, - null, - null, - jsonContent, - null, - null - ); + DocumentResponse response = DocumentResponse.builder() + .jsonContent(jsonContent) + .build(); assertThat(response.jsonContent()).isEqualTo(jsonContent); diff --git a/api/src/test/java/ai/docling/api/util/UtilsTests.java b/api/src/test/java/ai/docling/api/util/UtilsTests.java new file mode 100644 index 0000000..972adf2 --- /dev/null +++ b/api/src/test/java/ai/docling/api/util/UtilsTests.java @@ -0,0 +1,161 @@ +package ai.docling.api.util; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +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; + +class UtilsTests { + @Test + void get_or_default() { + assertThat(Utils.getOrDefault("foo", "bar")).isEqualTo("foo"); + assertThat(Utils.getOrDefault(null, "bar")).isEqualTo("bar"); + + assertThat(Utils.getOrDefault("foo", () -> "bar")).isEqualTo("foo"); + assertThat(Utils.getOrDefault(null, () -> "bar")).isEqualTo("bar"); + } + + @Test + void get_or_default_list() { + List nullList = null; + List emptyList = List.of(); + List list1 = List.of(1); + List list2 = List.of(2); + + assertThat(Utils.getOrDefault(nullList, nullList)).isSameAs(nullList); + assertThat(Utils.getOrDefault(nullList, emptyList)).isSameAs(emptyList); + + assertThat(Utils.getOrDefault(emptyList, nullList)).isSameAs(nullList); + assertThat(Utils.getOrDefault(emptyList, emptyList)).isSameAs(emptyList); + + assertThat(Utils.getOrDefault(nullList, list1)).isSameAs(list1); + assertThat(Utils.getOrDefault(emptyList, list1)).isSameAs(list1); + + assertThat(Utils.getOrDefault(list1, list2)).isSameAs(list1).isNotSameAs(list2); + } + + @Test + void get_or_default_map() { + Map nullMap = null; + Map emptyMap = Map.of(); + Map map1 = Map.of("one", "1"); + Map map2 = Map.of("two", "2"); + + assertThat(Utils.getOrDefault(nullMap, nullMap)).isSameAs(nullMap); + assertThat(Utils.getOrDefault(nullMap, emptyMap)).isSameAs(emptyMap); + + assertThat(Utils.getOrDefault(emptyMap, nullMap)).isSameAs(nullMap); + assertThat(Utils.getOrDefault(emptyMap, emptyMap)).isSameAs(emptyMap); + + assertThat(Utils.getOrDefault(nullMap, map1)).isSameAs(map1); + assertThat(Utils.getOrDefault(emptyMap, map1)).isSameAs(map1); + + assertThat(Utils.getOrDefault(map1, map2)).isSameAs(map1).isNotSameAs(map2); + } + + @Test + void is_null_or_blank() { + assertThat(Utils.isNullOrBlank(null)).isTrue(); + assertThat(Utils.isNullOrBlank("")).isTrue(); + assertThat(Utils.isNullOrBlank(" ")).isTrue(); + assertThat(Utils.isNullOrBlank("foo")).isFalse(); + + assertThat(Utils.isNotNullOrBlank(null)).isFalse(); + assertThat(Utils.isNotNullOrBlank("")).isFalse(); + assertThat(Utils.isNotNullOrBlank(" ")).isFalse(); + assertThat(Utils.isNotNullOrBlank("foo")).isTrue(); + } + + @Test + void string_is_null_or_empty() { + assertThat(Utils.isNullOrEmpty((String) null)).isTrue(); + assertThat(Utils.isNullOrEmpty("")).isTrue(); + assertThat(Utils.isNullOrEmpty(" ")).isFalse(); + assertThat(Utils.isNullOrEmpty("\n")).isFalse(); + assertThat(Utils.isNullOrEmpty("foo")).isFalse(); + } + + @Test + void string_is_not_null_or_empty() { + assertThat(Utils.isNotNullOrEmpty(null)).isFalse(); + assertThat(Utils.isNotNullOrEmpty("")).isFalse(); + assertThat(Utils.isNotNullOrEmpty(" ")).isTrue(); + assertThat(Utils.isNotNullOrEmpty("\n")).isTrue(); + assertThat(Utils.isNotNullOrEmpty("foo")).isTrue(); + } + + @Test + void collection_is_null_or_empty() { + assertThat(Utils.isNullOrEmpty((Collection) null)).isTrue(); + assertThat(Utils.isNullOrEmpty(emptyList())).isTrue(); + assertThat(Utils.isNullOrEmpty(Collections.singletonList("abc"))).isFalse(); + } + + @Test + void iterable_is_null_or_empty() { + assertThat(Utils.isNullOrEmpty((Iterable) null)).isTrue(); + assertThat(Utils.isNullOrEmpty((Iterable) emptyList())).isTrue(); + assertThat(Utils.isNullOrEmpty((Iterable) Collections.singletonList("abc"))) + .isFalse(); + } + + @Test + void array_is_null_or_empty() { + // Null array + assertThat(Utils.isNullOrEmpty((Object[]) null)).isTrue(); + + // Empty array + assertThat(Utils.isNullOrEmpty(new Object[0])).isTrue(); + + // Non-empty array with one element + assertThat(Utils.isNullOrEmpty(new Object[] {"abc"})).isFalse(); + + // Non-empty array with multiple elements + assertThat(Utils.isNullOrEmpty(new Object[] {"a", "b", "c"})).isFalse(); + + // Array with a null element (still non-empty) + assertThat(Utils.isNullOrEmpty(new Object[] {null})).isFalse(); + + // Mixed null and non-null elements + assertThat(Utils.isNullOrEmpty(new Object[] {null, "xyz"})).isFalse(); + } + + @MethodSource + @ParameterizedTest + void test_firstNotNull(Object[] values, Object expected) { + assertThat(Utils.firstNotNull("testParam", values)).isEqualTo(expected); + } + + static Stream test_firstNotNull() { + return Stream.of( + Arguments.of(new Object[] {"first", "second"}, "first"), + Arguments.of(new Object[] {null, "second"}, "second"), + Arguments.of(new Object[] {null, null, "third"}, "third"), + Arguments.of(new Object[] {42, null}, 42), + Arguments.of(new Object[] {null, true}, true)); + } + + @Test + void firstNotNull_throwsWhenAllValuesAreNull() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> Utils.firstNotNull("testParam", (Object) null, null)) + .withMessageContaining("At least one of the given 'testParam' values must be not null"); + } + + @Test + void firstNotNull_throwsWhenValuesArrayIsEmpty() { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> Utils.firstNotNull("testParam")) + .withMessageContaining("testParam values cannot be null or empty"); + } +} diff --git a/api/src/test/java/ai/docling/api/util/ValidationUtilsTests.java b/api/src/test/java/ai/docling/api/util/ValidationUtilsTests.java new file mode 100644 index 0000000..52de035 --- /dev/null +++ b/api/src/test/java/ai/docling/api/util/ValidationUtilsTests.java @@ -0,0 +1,264 @@ +package ai.docling.api.util; + +import static ai.docling.api.util.ValidationUtils.ensureBetween; +import static ai.docling.api.util.ValidationUtils.ensureEq; +import static ai.docling.api.util.ValidationUtils.ensureGreaterThanZero; +import static ai.docling.api.util.ValidationUtils.ensureNotNegative; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ValidationUtilsTests { + @Test + void ensure_eq() { + ensureEq(1, 1, "test"); + ensureEq("abc", "abc", "test"); + ensureEq(null, null, "test"); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureEq(1, 2, "test %d", 7)) + .withMessageContaining("test 7"); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureEq(1, null, "test")) + .withMessageContaining("test"); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureEq(null, 1, "test")) + .withMessageContaining("test"); + } + + @Test + void ensure_not_null() { + { + Object obj = new Object(); + assertThat(ValidationUtils.ensureNotNull(obj, "test")).isSameAs(obj); + } + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotNull(null, "test")) + .withMessage("test cannot be null"); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotNull(null, "test %d", 7)) + .withMessage("test 7"); + } + + @Test + void ensure_not_empty_string() { + { + String str = " abc "; + assertThat(ValidationUtils.ensureNotEmpty(str, "test")).isSameAs(str); + } + + { + String str = ""; + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty(str, "test")) + .withMessageContaining("test cannot be null or empty"); + } + + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty((String) null, "test")) + .withMessageContaining("test cannot be null or empty"); + } + } + + @Test + void ensure_not_empty_collection() { + { + List list = new ArrayList<>(); + list.add(new Object()); + assertThat(ValidationUtils.ensureNotEmpty(list, "test")).isSameAs(list); + } + + { + List list = new ArrayList<>(); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty(list, "test")) + .withMessageContaining("test cannot be null or empty"); + } + + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty((Collection) null, "test")) + .withMessageContaining("test cannot be null or empty"); + } + } + + @Test + void ensure_not_empty_array() { + { + Object[] array = {new Object()}; + assertThat(ValidationUtils.ensureNotEmpty(array, "test")).isSameAs(array); + } + + { + Object[] array = {}; + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty(array, "test")) + .withMessageContaining("test cannot be null or empty"); + } + + { + Object[] array = {}; + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> + ValidationUtils.ensureNotEmpty(array, "%s", "Parameterized type has no type arguments.")) + .withMessageContaining("Parameterized type has no type arguments."); + } + + { + Object[] array = null; + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty(array, "test")) + .withMessageContaining("test cannot be null or empty"); + } + } + + @Test + void ensure_not_empty_map() { + { + Map map = new HashMap<>(); + map.put(new Object(), new Object()); + assertThat(ValidationUtils.ensureNotEmpty(map, "test")).isSameAs(map); + } + + { + Map map = new HashMap<>(); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty(map, "test")) + .withMessageContaining("test cannot be null or empty"); + } + + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotEmpty((Map) null, "test")) + .withMessageContaining("test cannot be null or empty"); + } + } + + @Test + void ensure_not_blank() { + { + String str = " abc "; + assertThat(ValidationUtils.ensureNotBlank(str, "test")).isSameAs(str); + } + + { + String str = " "; + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotBlank(str, "test")) + .withMessageContaining("test cannot be null or blank"); + } + + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureNotBlank(null, "test")) + .withMessageContaining("test cannot be null or blank"); + } + } + + @Test + void ensure_true() { + { + ValidationUtils.ensureTrue(true, "test"); + } + + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ValidationUtils.ensureTrue(false, "test")) + .withMessageContaining("test"); + } + } + + @ParameterizedTest + @ValueSource(ints = { 0, 1, Integer.MAX_VALUE}) + void should_not_throw_when_positive(Integer i) { + ensureNotNegative(i, "integer"); + } + + @ParameterizedTest + @NullSource + @ValueSource(ints = {Integer.MIN_VALUE, -1}) + void should_throw_when_negative(Integer i) { + assertThatThrownBy(() -> ensureNotNegative(i, "integer")) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("integer must not be negative, but is: " + i); + } + + @ParameterizedTest + @ValueSource(ints = {1, Integer.MAX_VALUE}) + void should_not_throw_when_greater_than_0(Integer i) { + ensureGreaterThanZero(i, "integer"); + } + + @ParameterizedTest + @NullSource + @ValueSource(ints = {Integer.MIN_VALUE, 0}) + void should_throw_when_when_not_greater_than_0(Integer i) { + assertThatThrownBy(() -> ensureGreaterThanZero(i, "integer")) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("integer must be greater than zero, but is: " + i); + } + + @ParameterizedTest + @ValueSource(doubles = {0.0, 0.5, 1.0}) + void should_not_throw_when_between(Double d) { + ensureBetween(d, 0.0, 1.0, "test"); + } + + @ParameterizedTest + @NullSource + @ValueSource(doubles = {-0.1, 1.1}) + void should_throw_when_not_between(Double d) { + assertThatThrownBy(() -> ensureBetween(d, 0.0, 1.0, "test")) + .isExactlyInstanceOf(IllegalArgumentException.class) + .hasMessage("test must be between 0.0 and 1.0, but is: " + d); + } + + @Test + void ensure_between_int() { + { + ensureBetween(1, 0, 1, "test"); + } + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureBetween(2, 0, 1, "test")) + .withMessageContaining("test must be between 0 and 1, but is: 2"); + } + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureBetween(-1, 0, 1, "test")) + .withMessageContaining("test must be between 0 and 1, but is: -1"); + } + } + + @Test + void ensure_between_long() { + { + ensureBetween(1L, 0, 1, "test"); + } + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureBetween(2L, 0, 1, "test")) + .withMessageContaining("test must be between 0 and 1, but is: 2"); + } + { + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ensureBetween(-1L, 0, 1, "test")) + .withMessageContaining("test must be between 0 and 1, but is: -1"); + } + } +} diff --git a/buildSrc/src/main/kotlin/docling-java-shared.gradle.kts b/buildSrc/src/main/kotlin/docling-java-shared.gradle.kts index c49700a..63cdfc7 100644 --- a/buildSrc/src/main/kotlin/docling-java-shared.gradle.kts +++ b/buildSrc/src/main/kotlin/docling-java-shared.gradle.kts @@ -1,4 +1,5 @@ plugins { + id("docling-shared") `java-library` `maven-publish` } diff --git a/buildSrc/src/main/kotlin/docling-shared.gradle.kts b/buildSrc/src/main/kotlin/docling-shared.gradle.kts new file mode 100644 index 0000000..3f505e5 --- /dev/null +++ b/buildSrc/src/main/kotlin/docling-shared.gradle.kts @@ -0,0 +1,2 @@ +group = "ai.docling" +version = property("version").toString() diff --git a/client/src/main/java/ai/docling/client/DoclingClient.java b/client/src/main/java/ai/docling/client/DoclingClient.java index a09ed2d..df0f6f2 100644 --- a/client/src/main/java/ai/docling/client/DoclingClient.java +++ b/client/src/main/java/ai/docling/client/DoclingClient.java @@ -1,5 +1,8 @@ package ai.docling.client; +import static ai.docling.api.util.ValidationUtils.ensureNotBlank; +import static ai.docling.api.util.ValidationUtils.ensureNotNull; + import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -18,26 +21,29 @@ * Default implementation for the Docling API client. */ public class DoclingClient implements DoclingApi { - private static final URI DEFAULT_BASE_URL = URI.create("http://localhost:5001"); private final URI baseUrl; private final HttpClient httpClient; private final JsonMapper jsonMapper; - private DoclingClient(URI baseUrl, HttpClient httpClient, JsonMapper jsonMapper) { - this.baseUrl = baseUrl; - this.httpClient = httpClient; - this.jsonMapper = jsonMapper; - } + private DoclingClient(Builder builder) { + this.baseUrl = ensureNotNull(builder.baseUrl, "baseUrl"); - public static Builder builder() { - return new Builder(); + if (Objects.equals(this.baseUrl.getScheme(), "http")) { + // Docling Serve uses Python FastAPI which causes errors when called from JDK HttpClient. + // The HttpClient uses HTTP 2 by default and then falls back to HTTP 1.1 if not supported. + // However, the way FastAPI works results in the fallback not happening, making the call fail. + builder.httpClientBuilder.version(HttpClient.Version.HTTP_1_1); + } + + this.httpClient = ensureNotNull(builder.httpClientBuilder, "httpClientBuilder").build(); + this.jsonMapper = ensureNotNull(builder.jsonMapperBuilder, "jsonMapperBuilder").build(); } @Override public HealthCheckResponse health() { - HttpRequest httpRequest = HttpRequest.newBuilder() + var httpRequest = HttpRequest.newBuilder() .uri(baseUrl.resolve("/health")) .header("Accept", "application/json") .GET() @@ -51,7 +57,7 @@ public HealthCheckResponse health() { @Override public ConvertDocumentResponse convertSource(ConvertDocumentRequest request) { - HttpRequest httpRequest = HttpRequest.newBuilder() + var httpRequest = HttpRequest.newBuilder() .uri(baseUrl.resolve("/v1/convert/source")) .header("Accept", "application/json") .header("Content-Type", "application/json") @@ -64,56 +70,112 @@ public ConvertDocumentResponse convertSource(ConvertDocumentRequest request) { .join(); } - public static final class Builder { + @Override + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Creates and returns a new instance of {@link Builder} for constructing a {@link DoclingClient}. + * The builder allows customization of configuration such as base URL, HTTP client, and JSON mapper. + * + * @return a new {@link Builder} instance for creating a {@link DoclingClient}. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A builder class for creating instances of {@link DoclingClient}. This builder supports a fluent + * API for configuring properties such as the base URL, HTTP client, and JSON mapper. + * + *

The {@link Builder} provides customization options through the available methods and ensures + * proper validation of inputs during configuration. + * + *

An instance of {@code Builder} can be obtained via {@link DoclingClient#builder()} or + * {@link DoclingClient#toBuilder()}. + * + *

This class is an implementation of {@link DoclingApiBuilder}, allowing it to construct + * {@code DoclingClient} instances as part of the API construction process. + */ + public static final class Builder implements DoclingApiBuilder { private URI baseUrl = DEFAULT_BASE_URL; private HttpClient.Builder httpClientBuilder = HttpClient.newBuilder(); private JsonMapper.Builder jsonMapperBuilder = JsonMapper.builder(); - private Builder() {} + private Builder() { + } + private Builder(DoclingClient doclingClient) { + this.baseUrl = doclingClient.baseUrl; + this.httpClientBuilder = HttpClient.newBuilder(); + this.jsonMapperBuilder = doclingClient.jsonMapper.rebuild(); + } + + /** + * Sets the base URL for the Docling API service. + * + *

The URL string will be parsed and converted to a {@link URI}. + * + * @param baseUrl the base URL as a string (must not be blank) + * @return this {@link Builder} instance for method chaining + * @throws IllegalArgumentException if baseUrl is null or blank + */ public Builder baseUrl(String baseUrl) { - if (baseUrl == null || baseUrl.isBlank()) { - throw new IllegalArgumentException("baseUrl cannot be null or empty"); - } - this.baseUrl = URI.create(baseUrl); + this.baseUrl = URI.create(ensureNotBlank(baseUrl, "baseUrl")); return this; } + /** + * Sets the base URL for the Docling API service. + * + * @param baseUrl the base URL as a {@link URI} + * @return this {@link Builder} instance for method chaining + */ public Builder baseUrl(URI baseUrl) { - if (baseUrl == null) { - throw new IllegalArgumentException("baseUrl cannot be null"); - } this.baseUrl = baseUrl; return 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. + * + * @param httpClientBuilder the {@link HttpClient.Builder} to use + * @return this {@link Builder} instance for method chaining + */ public Builder httpClientBuilder(HttpClient.Builder httpClientBuilder) { - if (httpClientBuilder == null) { - throw new IllegalArgumentException("httpClientBuilder cannot be null"); - } this.httpClientBuilder = httpClientBuilder; return this; } + /** + * Sets the JSON mapper builder to be used for creating the JSON mapper. + * + *

This allows customization of JSON serialization and deserialization behavior, + * such as configuring features, modules, or property naming strategies. + * + * @param jsonMapperBuilder the {@link JsonMapper.Builder} to use + * @return this {@link Builder} instance for method chaining + */ public Builder jsonParser(JsonMapper.Builder jsonMapperBuilder) { - if (jsonMapperBuilder == null) { - throw new IllegalArgumentException("jsonMapperBuilder cannot be null"); - } this.jsonMapperBuilder = jsonMapperBuilder; return this; } + /** + * Builds and returns a new {@link DoclingClient} instance with the configured properties. + * + *

This method validates all required parameters and constructs the client. + * + * @return a new {@link DoclingClient} instance + * @throws IllegalArgumentException if any required parameter is null + */ + @Override public DoclingClient build() { - if (Objects.equals(baseUrl.getScheme(), "http")) { - // Docling Serve uses Python FastAPI which causes errors when called from JDK HttpClient. - // The HttpClient uses HTTP 2 by default and then falls back to HTTP 1.1 if not supported. - // However, the way FastAPI works results in the fallback not happening, making the call fail. - httpClientBuilder.version(HttpClient.Version.HTTP_1_1); - } - - return new DoclingClient(baseUrl, httpClientBuilder.build(), jsonMapperBuilder.build()); + return new DoclingClient(this); } - } - } diff --git a/client/src/test/java/ai/docling/client/DoclingClientTests.java b/client/src/test/java/ai/docling/client/DoclingClientTests.java index c54f6af..cb239d5 100644 --- a/client/src/test/java/ai/docling/client/DoclingClientTests.java +++ b/client/src/test/java/ai/docling/client/DoclingClientTests.java @@ -14,6 +14,7 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import ai.docling.api.DoclingApi; import ai.docling.api.convert.request.ConvertDocumentRequest; import ai.docling.api.convert.request.options.ConvertDocumentOptions; import ai.docling.api.convert.request.options.TableFormerMode; @@ -27,22 +28,21 @@ */ @Testcontainers class DoclingClientTests { - @Container private static final DoclingContainer doclingContainer = new DoclingContainer( DoclingContainerConfig.builder() - .imageName(Images.DOCLING) + .imageName(DoclingContainerConfig.DOCLING_IMAGE) .enableUi(true) .build(), Optional.of(Duration.ofMinutes(2)) ); - private static DoclingClient doclingClient; + private static DoclingApi doclingClient; @BeforeAll static void setUp() { doclingClient = DoclingClient.builder() - .baseUrl("http://localhost:%s".formatted(doclingContainer.getMappedPort(Images.DOCLING_DEFAULT_PORT))) + .baseUrl("http://localhost:%s".formatted(doclingContainer.getMappedPort(DoclingContainerConfig.DEFAULT_DOCLING_PORT))) .build(); } @@ -50,8 +50,10 @@ static void setUp() { void shouldSuccessfullyCallHealthEndpoint() { HealthCheckResponse response = doclingClient.health(); - assertThat(response).isNotNull(); - assertThat(response.status()).isEqualTo("ok"); + assertThat(response) + .isNotNull() + .extracting(HealthCheckResponse::status) + .isEqualTo("ok"); } @Test @@ -124,5 +126,4 @@ private static byte[] readFileFromClasspath(String filePath) throws IOException return inputStream.readAllBytes(); } } - } diff --git a/client/src/test/java/ai/docling/client/Images.java b/client/src/test/java/ai/docling/client/Images.java deleted file mode 100644 index b6df360..0000000 --- a/client/src/test/java/ai/docling/client/Images.java +++ /dev/null @@ -1,15 +0,0 @@ -package ai.docling.client; - -/** - * Images used in tests. - */ -public class Images { - - public static final String DOCLING = "ghcr.io/docling-project/docling-serve:v1.5.1"; - - public static final int DOCLING_DEFAULT_PORT = 5001; - - private Images() { - } - -} diff --git a/docs/build.gradle.kts b/docs/build.gradle.kts index 29381b1..185de75 100644 --- a/docs/build.gradle.kts +++ b/docs/build.gradle.kts @@ -1,15 +1,8 @@ plugins { - id("docling-java-shared") + id("docling-shared") id("ru.vyarus.mkdocs") version "4.0.1" } -dependencies { - compileOnly(project(":docling-api")) - compileOnly(project(":docling-client")) - compileOnly(project(":docling-testing")) - compileOnly(project(":docling-testcontainers")) -} - python { pip( "mkdocs:1.6.1", @@ -43,3 +36,7 @@ tasks.withType().configureEach { tasks.withType().configureEach { enabled = false } + +tasks.register("build") { + dependsOn(tasks.named("mkdocsBuild")) +} diff --git a/testcontainers/src/main/java/ai/docling/testcontainers/DoclingContainer.java b/testcontainers/src/main/java/ai/docling/testcontainers/DoclingContainer.java index 982f34a..8d8127b 100644 --- a/testcontainers/src/main/java/ai/docling/testcontainers/DoclingContainer.java +++ b/testcontainers/src/main/java/ai/docling/testcontainers/DoclingContainer.java @@ -20,11 +20,6 @@ public class DoclingContainer extends GenericContainer { private static final Logger LOG = Logger.getLogger(DoclingContainer.class.getName()); - /** - * The default container port that docling runs on - */ - public static final int DEFAULT_DOCLING_PORT = 5001; - /** * The dynamic host name determined from TestContainers */ @@ -43,7 +38,7 @@ public DoclingContainer(DoclingContainerConfig config, Optional timeou this.config = config; // Configure the container - withExposedPorts(DEFAULT_DOCLING_PORT); + withExposedPorts(DoclingContainerConfig.DEFAULT_DOCLING_PORT); withEnv(config.containerEnv()); waitingFor(Wait.forHttp("/health")); @@ -60,6 +55,6 @@ public DoclingContainer(DoclingContainerConfig config, Optional timeou * @return the dynamically mapped port on the host machine for the Docling container */ public int getPort() { - return getMappedPort(DEFAULT_DOCLING_PORT); + return getMappedPort(DoclingContainerConfig.DEFAULT_DOCLING_PORT); } } diff --git a/testcontainers/src/main/java/ai/docling/testcontainers/config/DoclingContainerConfig.java b/testcontainers/src/main/java/ai/docling/testcontainers/config/DoclingContainerConfig.java index 3603049..911190b 100644 --- a/testcontainers/src/main/java/ai/docling/testcontainers/config/DoclingContainerConfig.java +++ b/testcontainers/src/main/java/ai/docling/testcontainers/config/DoclingContainerConfig.java @@ -13,7 +13,12 @@ public interface DoclingContainerConfig { /** * Default image name */ - String DOCLING_IMAGE = "quay.io/docling-project/docling-serve:v1.6.0"; + String DOCLING_IMAGE = "ghcr.io/docling-project/docling-serve:v1.6.0"; + + /** + * The default container port that docling runs on + */ + int DEFAULT_DOCLING_PORT = 5001; /** * The container image name to use.