diff --git a/.github/project.yml b/.github/project.yml index 6cb8d1d1..81c76a35 100644 --- a/.github/project.yml +++ b/.github/project.yml @@ -1,4 +1,4 @@ release: previous-version: 0.4.7 - current-version: 0.4.8 - next-version: 0.4.9 + current-version: 0.5.0 + next-version: 0.5.1 diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeConvertApi.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeConvertApi.java index be669438..a8503462 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeConvertApi.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeConvertApi.java @@ -21,7 +21,7 @@ public interface DoclingServeConvertApi { * 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. + * @return a {@link ConvertDocumentResponse} describing the convert document response. * @throws ai.docling.serve.api.validation.ValidationException If request validation fails for any reason. */ ConvertDocumentResponse convertSource(ConvertDocumentRequest request); diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeTaskApi.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeTaskApi.java index 535c50ee..55267de7 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeTaskApi.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/DoclingServeTaskApi.java @@ -32,7 +32,7 @@ public interface DoclingServeTaskApi { TaskStatusPollResponse pollTaskStatus(TaskStatusPollRequest request); /** - * Converts the result of a completed task into a document format as specified in the request. + * Returns the result of a completed convert task. * * This method processes the task result identified by the unique task ID provided in * the request, performs any necessary transformation, and returns a response diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConvertDocumentResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConvertDocumentResponse.java index 7dc20b35..1eb48d66 100644 --- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConvertDocumentResponse.java +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConvertDocumentResponse.java @@ -1,90 +1,34 @@ package ai.docling.serve.api.convert.response; -import java.util.List; -import java.util.Map; - +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonSetter; -import com.fasterxml.jackson.annotation.Nulls; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; /** - * Response returned by the Convert API for a single conversion request. + * Abstract response returned by the Convert API for a single conversion request. * *

Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty * collections/strings are omitted from JSON output.

*/ @JsonInclude(JsonInclude.Include.NON_EMPTY) -@tools.jackson.databind.annotation.JsonDeserialize(builder = ConvertDocumentResponse.Builder.class) -@lombok.extern.jackson.Jacksonized -@lombok.Builder(toBuilder = true) -@lombok.Getter -@lombok.ToString -public class ConvertDocumentResponse { - /** - * Converted document content. - * - * @param document the converted document - * @return the converted document - */ - @JsonProperty("document") - private DocumentResponse document; - +@JsonTypeInfo( + use = JsonTypeInfo.Id.DEDUCTION +) +@JsonSubTypes({ + @JsonSubTypes.Type(InBodyConvertDocumentResponse.class), + @JsonSubTypes.Type(PreSignedUrlConvertDocumentResponse.class), + @JsonSubTypes.Type(ZipArchiveConvertDocumentResponse.class) +}) +public abstract sealed class ConvertDocumentResponse permits InBodyConvertDocumentResponse, PreSignedUrlConvertDocumentResponse, + ZipArchiveConvertDocumentResponse { /** - * List of errors that occurred during conversion. + * Type of response * - * @param errors the list of errors - * @return the list of errors + * @return the response type */ - @JsonProperty("errors") - @JsonSetter(nulls = Nulls.AS_EMPTY) - @lombok.Singular - private List errors; + @JsonProperty("response_type") + public abstract ResponseType getResponseType(); - /** - * Total processing time in seconds. - * - * @param processingTime the processing time in seconds - * @return the processing time in seconds - */ - @JsonProperty("processing_time") - private Double processingTime; - - /** - * Conversion status (success, failure, partial_success, etc.). - * - * @param status the conversion status - * @return the conversion status - */ - @JsonProperty("status") - private String status; - - /** - * Detailed timing information for processing stages. - * - * @param timings the map of timing information - * @return the map of timing information - */ - @JsonProperty("timings") - @JsonSetter(nulls = Nulls.AS_EMPTY) - @lombok.Singular - private Map timings; - - /** - * Builder for creating {@link ConvertDocumentResponse} instances. - * Generated by Lombok's {@code @Builder} annotation. - * - *

Builder methods: - *

- */ - @tools.jackson.databind.annotation.JsonPOJOBuilder(withPrefix = "") - public static class Builder { } } diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/InBodyConvertDocumentResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/InBodyConvertDocumentResponse.java new file mode 100644 index 00000000..851e6bcc --- /dev/null +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/InBodyConvertDocumentResponse.java @@ -0,0 +1,111 @@ +package ai.docling.serve.api.convert.response; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.annotation.Nulls; + +/** + * Response for single document conversions with in-body content delivery. + * + *

This response type is returned when:

+ * + * + *

The converted document content is included directly in the response body, + * along with conversion status, processing time, errors, and detailed timing + * information for each processing stage.

+ * + *

Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty + * collections/strings are omitted from JSON output.

+ * + * @see ConvertDocumentResponse + * @see ResponseType#InBodyConvertDocumentResponse + * @see ai.docling.serve.api.convert.request.target.InBodyTarget + * @see DocumentResponse + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@tools.jackson.databind.annotation.JsonDeserialize(builder = InBodyConvertDocumentResponse.Builder.class) +@lombok.extern.jackson.Jacksonized +@lombok.Builder(toBuilder = true) +@lombok.Getter +@lombok.ToString +public final class InBodyConvertDocumentResponse extends ConvertDocumentResponse { + /** + * Converted document content. + * + * @param document the converted document + * @return the converted document + */ + @JsonProperty("document") + private DocumentResponse document; + + /** + * List of errors that occurred during conversion. + * + * @param errors the list of errors + * @return the list of errors + */ + @JsonProperty("errors") + @JsonSetter(nulls = Nulls.AS_EMPTY) + @lombok.Singular + private List errors; + + /** + * Total processing time in seconds. + * + * @param processingTime the processing time in seconds + * @return the processing time in seconds + */ + @JsonProperty("processing_time") + private Double processingTime; + + /** + * Conversion status (success, failure, partial_success, etc.). + * + * @param status the conversion status + * @return the conversion status + */ + @JsonProperty("status") + private String status; + + /** + * Detailed timing information for processing stages. + * + * @param timings the map of timing information + * @return the map of timing information + */ + @JsonProperty("timings") + @JsonSetter(nulls = Nulls.AS_EMPTY) + @lombok.Singular + private Map timings; + + @Override + @lombok.ToString.Include + public ResponseType getResponseType() { + return ResponseType.InBodyConvertDocumentResponse; + } + + /** + * Builder for creating {@link InBodyConvertDocumentResponse} instances. + * Generated by Lombok's {@code @Builder} annotation. + * + *

Builder methods: + *

    + *
  • {@code document(DocumentResponse)} - Set the converted document
  • + *
  • {@code error(ErrorItem)} - Add a single error (use with @Singular)
  • + *
  • {@code errors(List)} - Set the list of errors
  • + *
  • {@code processingTime(Double)} - Set the processing time in seconds
  • + *
  • {@code status(String)} - Set the conversion status
  • + *
  • {@code timing(String, Object)} - Add a single timing entry (use with @Singular)
  • + *
  • {@code timings(Map)} - Set the map of timing information
  • + *
+ */ + @tools.jackson.databind.annotation.JsonPOJOBuilder(withPrefix = "") + public static class Builder { } +} diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertDocumentResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertDocumentResponse.java new file mode 100644 index 00000000..0e712992 --- /dev/null +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertDocumentResponse.java @@ -0,0 +1,93 @@ +package ai.docling.serve.api.convert.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response for document conversions with S3 or PUT target types. + * + *

This response type is returned when the conversion request specifies + * an S3 target or PUT target. The converted documents are stored in the + * specified location. + * The response includes processing statistics and conversion metrics.

+ * + *

Use cases:

+ *
    + *
  • Target type is {@link ai.docling.serve.api.convert.request.target.S3Target}
  • + *
  • Target type is {@link ai.docling.serve.api.convert.request.target.PutTarget}
  • + *
+ * + *

Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty + * collections/strings are omitted from JSON output.

+ * + * @see ConvertDocumentResponse + * @see ResponseType#PreSignedUrlConvertDocumentResponse + * @see ai.docling.serve.api.convert.request.target.S3Target + * @see ai.docling.serve.api.convert.request.target.PutTarget + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@tools.jackson.databind.annotation.JsonDeserialize(builder = PreSignedUrlConvertDocumentResponse.Builder.class) +@lombok.extern.jackson.Jacksonized +@lombok.Builder(toBuilder = true) +@lombok.Getter +@lombok.ToString +public final class PreSignedUrlConvertDocumentResponse extends ConvertDocumentResponse { + + /** + * Total processing time in seconds. + * + * @param processingTime the processing time in seconds + * @return the processing time in seconds + */ + @JsonProperty("processing_time") + private Double processingTime; + + /** + * Number of attempted conversions + * + * @param numSucceeded the number of attempted conversions + * @return the number of attempted conversions + */ + @JsonProperty("num_converted") + private Integer numConverted; + + /** + * Number of successful conversions + * + * @param numSucceeded the number of successful conversions + * @return the number of successful conversions + */ + @JsonProperty("num_succeeded") + private Integer numSucceeded; + + /** + * Number of failed conversions + * + * @param numSucceeded the number of failed conversions + * @return the number of failed conversions + */ + @JsonProperty("num_failed") + private Integer numFailed; + + @Override + @lombok.ToString.Include + public ResponseType getResponseType() { + return ResponseType.PreSignedUrlConvertDocumentResponse; + } + + /** + * Builder for creating {@link PreSignedUrlConvertDocumentResponse} instances. + * Generated by Lombok's {@code @Builder} annotation. + * + *

Builder methods: + *

    + *
  • {@code processingTime(Double)} - Set the processing time in seconds
  • + *
  • {@code numConverted(Integer)} - Set the number of successful conversions
  • + *
  • {@code numFailed(Integer)} - Set the number of failed conversions
  • + *
  • {@code numConverted(Integer)} - Set the number of attempted conversions
  • + *
+ */ + @tools.jackson.databind.annotation.JsonPOJOBuilder(withPrefix = "") + public static class Builder { } + +} diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ResponseType.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ResponseType.java new file mode 100644 index 00000000..e91c519f --- /dev/null +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ResponseType.java @@ -0,0 +1,9 @@ +package ai.docling.serve.api.convert.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum ResponseType { + @JsonProperty("in_body") InBodyConvertDocumentResponse, + @JsonProperty("zip_archive") ZipArchiveConvertDocumentResponse, + @JsonProperty("presigned_url") PreSignedUrlConvertDocumentResponse +} diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ZipArchiveConvertDocumentResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ZipArchiveConvertDocumentResponse.java new file mode 100644 index 00000000..7c3585bf --- /dev/null +++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ZipArchiveConvertDocumentResponse.java @@ -0,0 +1,87 @@ +package ai.docling.serve.api.convert.response; + +import java.io.InputStream; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Response for document conversions delivered as a ZIP archive. + * + *

This response type is returned in any one the following scenarios:

+ *
    + *
  • The conversion request contains multiple source files
  • + *
  • The target type is {@link ai.docling.serve.api.convert.request.target.ZipTarget}
  • + *
+ * + *

The response includes the ZIP file name and an input stream to read + * the archive contents. All converted documents and their associated assets + * are packaged together in the ZIP file.

+ * + *

Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty + * collections/strings are omitted from JSON output.

+ * + * @see ConvertDocumentResponse + * @see ResponseType#ZipArchiveConvertDocumentResponse + * @see ai.docling.serve.api.convert.request.target.ZipTarget + * @see ai.docling.serve.api.convert.request.ConvertDocumentRequest + */ +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@tools.jackson.databind.annotation.JsonDeserialize(builder = ZipArchiveConvertDocumentResponse.Builder.class) +@lombok.extern.jackson.Jacksonized +@lombok.Builder(toBuilder = true) +@lombok.Getter +@lombok.ToString +public final class ZipArchiveConvertDocumentResponse extends ConvertDocumentResponse { + + /** + * Name of the ZIP archive file containing the converted documents. + * + *

This is the suggested filename for saving the ZIP archive locally.

+ * + * @param fileName the ZIP archive file name + * @return the ZIP archive file name + */ + + @JsonProperty("file_name") + @lombok.Builder.Default + private String fileName = "converted_docs.zip"; + + /** + * Input stream for reading the ZIP archive contents. + * + *

This stream provides access to the binary ZIP file data containing + * all converted documents and their assets. The stream should be properly + * closed after reading to release resources.

+ * + *

Note: This field is typically not serialized to JSON + * as it represents binary data. It's used for programmatic access to the + * ZIP archive contents.

+ * + * @param inputStream the input stream for the ZIP archive + * @return the input stream for the ZIP archive + */ + @JsonIgnore + private InputStream inputStream; + + @Override + @lombok.ToString.Include + public ResponseType getResponseType() { + return ResponseType.ZipArchiveConvertDocumentResponse; + } + + /** + * Builder for creating {@link ZipArchiveConvertDocumentResponse} instances. + * Generated by Lombok's {@code @Builder} annotation. + * + *

Builder methods:

+ *
    + *
  • {@code fileName(String)} - Set the ZIP archive file name
  • + *
  • {@code inputStream(InputStream)} - Set the input stream for the ZIP archive
  • + *
+ */ + @tools.jackson.databind.annotation.JsonPOJOBuilder(withPrefix = "") + public static class Builder { } + +} diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ConvertDocumentResponseTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ConvertDocumentResponseTests.java index 38bfc26c..f6f95a73 100644 --- a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ConvertDocumentResponseTests.java +++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ConvertDocumentResponseTests.java @@ -43,7 +43,7 @@ void createResponseWithAllFields() { "convert_time", 0.7 ); - ConvertDocumentResponse response = ConvertDocumentResponse.builder() + InBodyConvertDocumentResponse response = InBodyConvertDocumentResponse.builder() .document(document) .errors(errors) .processingTime(processingTime) @@ -59,8 +59,8 @@ void createResponseWithAllFields() { } @Test - void createResponseWithNullFields() { - ConvertDocumentResponse response = ConvertDocumentResponse.builder().build(); + void createInBodyConvertDocumentResponseWithNullFields() { + InBodyConvertDocumentResponse response = InBodyConvertDocumentResponse.builder().build(); assertThat(response.getDocument()).isNull(); assertThat(response.getErrors()).isNotNull().isEmpty(); @@ -69,6 +69,24 @@ void createResponseWithNullFields() { assertThat(response.getTimings()).isNotNull().isEmpty(); } + @Test + void createZipArchiveConvertDocumentResponseWithNullFields() { + ZipArchiveConvertDocumentResponse response = ZipArchiveConvertDocumentResponse.builder().build(); + + assertThat(response.getInputStream()).isNull(); + assertThat(response.getFileName()).isNotNull().isEqualTo("converted_docs.zip"); + } + + @Test + void createPreSignedUrlConvertDocumentResponseWithNullFields() { + PreSignedUrlConvertDocumentResponse response = PreSignedUrlConvertDocumentResponse.builder().build(); + + assertThat(response.getNumConverted()).isNull(); + assertThat(response.getNumFailed()).isNull(); + assertThat(response.getProcessingTime()).isNull(); + assertThat(response.getNumSucceeded()).isNull(); + } + @Test void createResponseWithEmptyCollections() { DocumentResponse document = DocumentResponse.builder() @@ -76,7 +94,7 @@ void createResponseWithEmptyCollections() { .textContent("") .build(); - ConvertDocumentResponse response = ConvertDocumentResponse.builder() + InBodyConvertDocumentResponse response = InBodyConvertDocumentResponse.builder() .document(document) .processingTime(0.1) .status("completed") @@ -97,7 +115,7 @@ void convertDocumentResponseIsImmutable() { Map timings = new HashMap<>(Map.of("original_time", 1.0)); - ConvertDocumentResponse response = ConvertDocumentResponse.builder() + InBodyConvertDocumentResponse response = InBodyConvertDocumentResponse.builder() .errors(errors) .timings(timings) .build(); 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 0c2a2b17..cc3c5da8 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 @@ -3,6 +3,7 @@ import static ai.docling.serve.api.util.ValidationUtils.ensureNotNull; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpClient.Redirect; @@ -12,6 +13,7 @@ import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; import java.util.Map; @@ -52,6 +54,7 @@ import ai.docling.serve.client.operations.HealthOperations; import ai.docling.serve.client.operations.HttpOperations; import ai.docling.serve.client.operations.RequestContext; +import ai.docling.serve.client.operations.StreamResponse; import ai.docling.serve.client.operations.TaskOperations; /** @@ -214,7 +217,12 @@ protected T execute(HttpRequest request, Class expectedValueType) { long startTime = System.currentTimeMillis(); try { - var response = this.httpClient.send(request, BodyHandlers.ofString()); + HttpResponse response = null; + if(StreamResponse.class.equals(expectedValueType)) + response = this.httpClient.send(request, BodyHandlers.ofInputStream()); + else + response = this.httpClient.send(request, BodyHandlers.ofString()); + return getResponse(request, response, expectedValueType); } catch (IOException | InterruptedException e) { @@ -236,6 +244,16 @@ protected O executePost(RequestContext requestContext) { return execute(httpRequest, requestContext.getResponseType()); } + @Override + protected StreamResponse executePostWithStreamResponse(RequestContext requestContext) { + var httpRequest = createRequestBuilder(requestContext) + .header("Accept", "application/octet-stream") + .header("Content-Type", "application/json") + .POST(new LoggingBodyPublisher<>(requestContext.getRequest())) + .build(); + return execute(httpRequest, requestContext.getResponseType()); + } + @Override protected O executeGet(RequestContext requestContext) { var httpRequest = createRequestBuilder(requestContext) @@ -245,6 +263,15 @@ protected O executeGet(RequestContext requestContext) { return execute(httpRequest, requestContext.getResponseType()); } + @Override + protected StreamResponse executeGetWithStreamResponse(RequestContext requestContext) { + var httpRequest = createRequestBuilder(requestContext) + .header("Accept", "application/octet-stream") + .GET() + .build(); + return execute(httpRequest, requestContext.getResponseType()); + } + protected HttpRequest.Builder createRequestBuilder(RequestContext requestContext) { var requestBuilder = HttpRequest.newBuilder() .uri(this.baseUrl.resolve(resolvePath(requestContext.getUri()))) @@ -265,28 +292,51 @@ private String resolvePath(String path) { .orElse(path); } - protected T getResponse(HttpRequest request, HttpResponse response, Class expectedReturnType) { + protected T getResponse(HttpRequest request, HttpResponse response, Class expectedReturnType) { var body = response.body(); - if (this.logResponses) { - logResponse(response, Optional.ofNullable(body)); + // if expectedReturnType is StreamResponse.class, avoid logging potential binary data + if (this.logResponses && !(StreamResponse.class.equals(expectedReturnType))) { + logResponse((HttpResponse) response, Optional.ofNullable(body.toString())); } var statusCode = response.statusCode(); if (statusCode == 422) { + if(StreamResponse.class.equals(expectedReturnType)) { + try { + body = new String(((InputStream)body).readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new DoclingServeClientException(e); + } + } throw new ValidationException( - readValue(body, ValidationError.class), + readValue(body.toString(), ValidationError.class), "An error occurred while making %s request to %s".formatted(request.method(), request.uri()) ); } else if (statusCode >= 400) { // Handle errors // The Java HTTPClient doesn't throw exceptions on error codes - throw new DoclingServeClientException("An error occurred: %s".formatted(body), statusCode, body); + if(StreamResponse.class.equals(expectedReturnType)) { + try { + body = new String(((InputStream)body).readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new DoclingServeClientException(e); + } + } + throw new DoclingServeClientException("An error occurred: %s".formatted(body.toString()), statusCode, body.toString()); } - return readValue(body, expectedReturnType); + if(StreamResponse.class.equals(expectedReturnType)) { + return (T) StreamResponse + .builder() + .headers(headerName -> response.headers().firstValue(headerName)) + .body((InputStream)body) + .build(); + } else { + return readValue(body.toString(), expectedReturnType); + } } @Override @@ -500,7 +550,7 @@ public B readTimeout(Duration readTimeout) { * 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(ConvertDocumentRequest)} (ConvertDocumentRequest). * * @param asyncPollInterval the polling interval (must not be null or negative) * @return this builder instance for method chaining @@ -515,7 +565,7 @@ public B asyncPollInterval(Duration asyncPollInterval) { * 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(ConvertDocumentRequest)} (ConvertDocumentRequest). * * @param asyncTimeout the timeout duration (must not be null or negative) * @return this builder instance for method chaining 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 f7942f14..db3bbd74 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 @@ -76,7 +76,6 @@ public static final class Builder extends DoclingServeClientBuilder 1; + boolean isRemoteTarget = request.getTarget() instanceof S3Target || request.getTarget() instanceof PutTarget; + boolean isZipTarget = request.getTarget() instanceof ZipTarget; + + if((hasMultipleSources && !isRemoteTarget) || isZipTarget) { + StreamResponse response = this.httpOperations + .executePostWithStreamResponse(createRequestContext(uri, request, + StreamResponse.class)); + String fileName = Utils.getFileName(response.getHeaders()).orElse("converted_docs.zip"); + return ZipArchiveConvertDocumentResponse + .builder().fileName(fileName) + .inputStream(response.getBody()) + .build(); + } else { + return this.httpOperations.executePost(createRequestContext(uri, request, + ConvertDocumentResponse.class)); + } } - private RequestContext createRequestContext(String uri, I request) { - return RequestContext.builder() + private RequestContext createRequestContext(String uri, I request, Class responseType) { + return RequestContext.builder() .request(request) - .responseType(ConvertDocumentResponse.class) + .responseType(responseType) .uri(uri) .build(); } diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java index 0c846acf..2250b641 100644 --- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/HttpOperations.java @@ -6,6 +6,21 @@ * implement these operations for specific use cases. */ public abstract class HttpOperations { + + /** + * Content-Type header key + */ + public static final String CONTENT_TYPE_HEADER = "Content-Type"; + /** + * Content-Type header value for JSON body + */ + public static final String CONTENT_TYPE_JSON = "application/json"; + + /** + * Content-Type header value for ZIP binary body + */ + public static final String CONTENT_TYPE_ZIP = "application/zip"; + /** * The header name used to specify the API key in HTTP requests. * This constant is commonly utilized in authentication mechanisms @@ -24,6 +39,16 @@ public abstract class HttpOperations { */ protected abstract O executeGet(RequestContext requestContext); + /** + * Executes an HTTP GET request using the details specified in the provided {@code RequestContext}. + * + * @param the type of the request payload + * @param requestContext the context containing details such as the URI, request payload, + * and expected response type of the GET operation + * @return an instance of the {@link StreamResponse}, which represents the response. + */ + protected abstract StreamResponse executeGetWithStreamResponse(RequestContext requestContext); + /** * Executes an HTTP POST request using the details provided in the {@code RequestContext}. * This method is designed to be implemented by subclasses and facilitates sending POST requests @@ -36,4 +61,27 @@ public abstract class HttpOperations { * @return an instance of the response type {@code O}, which represents the deserialized response data */ protected abstract O executePost(RequestContext requestContext); + + /** + * Executes an HTTP POST request using the details provided in the {@code RequestContext}. + * This method is designed to be implemented by subclasses and facilitates sending POST requests + * with a specified request payload and receiving a stream response. + * + * @param the type of the request payload + * @param requestContext the context containing details such as the URI, request payload, and + * expected response type of the POST operation + * @return an instance of the {@link StreamResponse}, which represents the response. + */ + protected abstract StreamResponse executePostWithStreamResponse(RequestContext requestContext); + + /** + * Reads and deserializes the given JSON string into an instance of the specified type. + * + * @param json the JSON string to deserialize; must not be {@code null} + * @param valueType the {@link Class} of the target type; must not be {@code null} + * @param the type of the object to be deserialized + * @return an instance of {@code T} deserialized from the provided JSON + * @throws RuntimeException if the JSON parsing fails + */ + protected abstract T readValue(String json, Class valueType); } diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/StreamResponse.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/StreamResponse.java new file mode 100644 index 00000000..6ee33d0d --- /dev/null +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/StreamResponse.java @@ -0,0 +1,67 @@ +package ai.docling.serve.client.operations; + +import java.io.InputStream; +import java.util.Optional; + +/** + * Wrapper for HTTP responses containing binary stream data. + * Provides an abstraction layer to decouple from specific HTTP client implementations. + */ +public class StreamResponse { + private final InputStream body; + private final ResponseHeaders headers; + + private StreamResponse(Builder builder) { + this.body = builder.body; + this.headers = builder.headers; + } + + public InputStream getBody() { return body; } + + + public ResponseHeaders getHeaders() { return headers; } + + public static Builder builder() { return new Builder(); } + + public Builder toBuilder() { return new Builder(this); } + + public static class Builder { + private InputStream body; + private ResponseHeaders headers; + + public Builder() {} + + public Builder(StreamResponse streamResponse) { + this.body = streamResponse.body; + this.headers = streamResponse.headers; + } + + public Builder body(InputStream body) { + this.body = body; + return this; + } + + public Builder headers(ResponseHeaders headers) { + this.headers = headers; + return this; + } + + public StreamResponse build() { + return new StreamResponse(this); + } + } + + /** + * Abstraction for HTTP response headers. + */ + @FunctionalInterface + public interface ResponseHeaders { + /** + * Gets the first value of the specified header. + * + * @param headerName the name of the header + * @return an Optional containing the first header value, or empty if not found + */ + Optional getFirstValue(String headerName); + } +} diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/TaskOperations.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/TaskOperations.java index 898957ef..4bf0702a 100644 --- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/TaskOperations.java +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/TaskOperations.java @@ -1,12 +1,18 @@ package ai.docling.serve.client.operations; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + import ai.docling.serve.api.DoclingServeTaskApi; import ai.docling.serve.api.chunk.response.ChunkDocumentResponse; import ai.docling.serve.api.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.ZipArchiveConvertDocumentResponse; import ai.docling.serve.api.task.request.TaskResultRequest; import ai.docling.serve.api.task.request.TaskStatusPollRequest; import ai.docling.serve.api.task.response.TaskStatusPollResponse; import ai.docling.serve.api.util.ValidationUtils; +import ai.docling.serve.client.DoclingServeClientException; +import ai.docling.serve.client.util.Utils; /** * Base class for task API operations. Provides operations for managing and querying @@ -31,7 +37,7 @@ public TaskOperations(HttpOperations httpOperations) { * unique task identifier and optional wait time for polling. * Must not be null. * @return a {@link TaskStatusPollResponse} containing the current status of - * the task, its position in the queue, and any associated metadata. + * the task, its position in the queue, and any associated metadata. * @throws IllegalArgumentException if the {@code request} is null. */ public TaskStatusPollResponse pollTaskStatus(TaskStatusPollRequest request) { @@ -46,21 +52,39 @@ public TaskStatusPollResponse pollTaskStatus(TaskStatusPollRequest request) { } /** - * Retrieves the result of a completed task identified by the specified task ID. + * Retrieves the result of a completed convert task identified by the specified task ID. * * This method sends a GET request to fetch the result of a task that has been processed. - * The response includes details about the converted document, processing time, status, - * and any potential errors or additional metadata related to the task. * * @param request an instance of {@link TaskResultRequest} containing the unique task * identifier. Must not be null. * @return a {@link ConvertDocumentResponse} containing details about the converted - * document, processing time, status, and any associated errors or metadata. + * document, processing time, status, and any associated errors or metadata. * @throws IllegalArgumentException if {@code request} is null. */ public ConvertDocumentResponse convertTaskResult(TaskResultRequest request) { ValidationUtils.ensureNotNull(request, "request"); - return this.httpOperations.executeGet(createRequestContext("/v1/result/%s".formatted(request.getTaskId()), ConvertDocumentResponse.class)); + StreamResponse response = this.httpOperations + .executeGetWithStreamResponse(createRequestContext("/v1/result/%s".formatted(request.getTaskId()), StreamResponse.class)); + switch (Utils.getContentType(response.getHeaders()).orElse("Unknown Content-Type")) { + case HttpOperations.CONTENT_TYPE_JSON -> { + try { + return httpOperations + .readValue(new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8) + , ConvertDocumentResponse.class); + } catch (IOException e) { + throw new DoclingServeClientException(e); + } + } + case HttpOperations.CONTENT_TYPE_ZIP -> { + String fileName = Utils.getFileName(response.getHeaders()).orElse("converted_docs.zip"); + return ZipArchiveConvertDocumentResponse + .builder().fileName(fileName) + .inputStream(response.getBody()) + .build(); + } + default -> throw new DoclingServeClientException(null, "Content-Type missing in task api response"); + } } /** @@ -73,7 +97,7 @@ public ConvertDocumentResponse convertTaskResult(TaskResultRequest request) { * @param request an instance of {@link TaskResultRequest} containing the unique task * identifier. Must not be null. * @return a {@link ChunkDocumentResponse} containing details about the chunks, - * documents, processing time, and any associated metadata. + * documents, processing time, and any associated metadata. * @throws IllegalArgumentException if {@code request} is null. */ public ChunkDocumentResponse chunkTaskResult(TaskResultRequest request) { diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/Utils.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/Utils.java new file mode 100644 index 00000000..ea83ad8d --- /dev/null +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/Utils.java @@ -0,0 +1,30 @@ +package ai.docling.serve.client.util; + +import java.util.Optional; + +import ai.docling.serve.client.operations.HttpOperations; +import ai.docling.serve.client.operations.StreamResponse; + +public class Utils { + + public static Optional getFileName(StreamResponse.ResponseHeaders headers) { + return headers + .getFirstValue("Content-Disposition") + .filter(ai.docling.serve.api.util.Utils::isNotNullOrBlank) + .map(contentDisposition -> { + int filenameIndex = contentDisposition.indexOf("filename="); + if (filenameIndex==-1) { + return null; + } + + String fileName = contentDisposition.substring(filenameIndex + "filename=".length()); + return fileName.replaceAll("^\"|\"$", "").trim(); + }) + .filter(ai.docling.serve.api.util.Utils::isNotNullOrBlank); + } + + public static Optional getContentType(StreamResponse.ResponseHeaders headers) { + return headers.getFirstValue(HttpOperations.CONTENT_TYPE_HEADER) + .filter(ai.docling.serve.api.util.Utils::isNotNullOrBlank); + } +} diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/package-info.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/package-info.java new file mode 100644 index 00000000..449971ae --- /dev/null +++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/util/package-info.java @@ -0,0 +1,4 @@ +@NullMarked +package ai.docling.serve.client.util; + +import org.jspecify.annotations.NullMarked; 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 3acfbbd2..85783935 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 @@ -17,6 +17,7 @@ import static org.awaitility.Awaitility.await; import java.io.IOException; +import java.io.InputStream; import java.lang.reflect.Method; import java.net.URI; import java.net.http.HttpClient; @@ -33,8 +34,12 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.Flow.Subscriber; import java.util.concurrent.atomic.AtomicReference; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.assertj.core.api.InstanceOfAssertFactories; import org.jspecify.annotations.Nullable; @@ -63,17 +68,25 @@ import ai.docling.serve.api.clear.response.ClearResponse; import ai.docling.serve.api.convert.request.ConvertDocumentRequest; import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; +import ai.docling.serve.api.convert.request.options.ImageRefMode; import ai.docling.serve.api.convert.request.options.OutputFormat; import ai.docling.serve.api.convert.request.options.TableFormerMode; import ai.docling.serve.api.convert.request.source.HttpSource; import ai.docling.serve.api.convert.request.source.S3Source; +import ai.docling.serve.api.convert.request.target.PutTarget; import ai.docling.serve.api.convert.request.target.S3Target; +import ai.docling.serve.api.convert.request.target.ZipTarget; import ai.docling.serve.api.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; +import ai.docling.serve.api.convert.response.PreSignedUrlConvertDocumentResponse; +import ai.docling.serve.api.convert.response.ResponseType; +import ai.docling.serve.api.convert.response.ZipArchiveConvertDocumentResponse; import ai.docling.serve.api.health.HealthCheckResponse; import ai.docling.serve.api.task.request.TaskResultRequest; import ai.docling.serve.api.task.request.TaskStatusPollRequest; import ai.docling.serve.api.task.response.TaskStatus; import ai.docling.serve.api.task.response.TaskStatusPollResponse; +import ai.docling.serve.api.util.FileUtils; import ai.docling.serve.api.validation.ValidationError; import ai.docling.serve.api.validation.ValidationErrorContext; import ai.docling.serve.api.validation.ValidationErrorDetail; @@ -269,7 +282,7 @@ void convertUrlTaskResult() throws IOException, InterruptedException { .build(); var result = getDoclingClient().convertTaskResult(request); - ConvertTests.assertConvertHttpSource(result); + ConvertTests.assertConvertSingleHttpSourceWithDefaultTarget(result); } @Test @@ -439,17 +452,34 @@ void shouldSuccessfullyCallHealthEndpoint() { @Nested class ConvertTests { - static void assertConvertHttpSource(ConvertDocumentResponse response) { + static void assertConvertSingleHttpSourceWithDefaultTarget(ConvertDocumentResponse response) { assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); - assertThat(response.getDocument().getFilename()).isNotEmpty(); - - if (response.getProcessingTime() != null) { - assertThat(response.getProcessingTime()).isPositive(); + assertThat(response.getResponseType().equals(ResponseType.InBodyConvertDocumentResponse)).isTrue(); + var inBodyResponse = (InBodyConvertDocumentResponse)response; + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); + assertThat(inBodyResponse.getDocument().getFilename()).isNotEmpty(); + + if (inBodyResponse.getProcessingTime() != null) { + assertThat(inBodyResponse.getProcessingTime()).isPositive(); } - assertThat(response.getDocument().getMarkdownContent()).isNotEmpty(); + assertThat(inBodyResponse.getDocument().getMarkdownContent()).isNotEmpty(); + } + + static void assertZipArchiveEntries(InputStream inputStream, Set expectedEntries) { + Set actualEntries = new TreeSet<>(); + try (ZipInputStream zipInputStream = new ZipInputStream(inputStream)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + actualEntries.add(entry.getName()); + LOG.info("Found entry in ZIP: {} (size: {} bytes)", entry.getName(), entry.getSize()); + zipInputStream.closeEntry(); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + assertThat(actualEntries).containsExactlyInAnyOrderElementsOf(expectedEntries); } @Test @@ -497,6 +527,59 @@ void shouldThrowValidationError() { ); } + @Test + void shouldConvertSourceWithPutTargetSuccessfully() { + var request = ConvertDocumentRequest.builder() + .source( + HttpSource + .builder() + .url(URI.create("https://docs.arconia.io/arconia-cli/latest/development/dev/")) + .build() + ) + .target( + PutTarget.builder().url(URI.create("https://github.com/docling-project/docling-java/save")).build() + ).build(); + + var wireMockServer = getWiremockServer(); + + wireMockServer.stubFor( + post("/v1/convert/source") + .withRequestBody(equalToJson(writeValueAsString(request))) + .withHeader("Content-Type", equalTo("application/json")) + .withHeader("Accept", equalTo("application/json")) + .willReturn(okJson(""" + { + "processing_time": 7, + "num_converted": 10, + "num_succeeded": 5, + "num_failed": 5 + } + """)) + ); + + var response = getDoclingClient(false, true).convertSource(request); + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.PreSignedUrlConvertDocumentResponse)).isTrue(); + var preSignedUrlResponse = (PreSignedUrlConvertDocumentResponse)response; + + assertThat(preSignedUrlResponse.getNumConverted()).isEqualTo(10); + assertThat(preSignedUrlResponse.getNumSucceeded()).isEqualTo(5); + assertThat(preSignedUrlResponse.getNumFailed()).isEqualTo(5); + assertThat(preSignedUrlResponse.getProcessingTime()).isPositive().isEqualTo(7); + + wireMockServer.verify( + 1, + postRequestedFor(urlPathEqualTo("/v1/convert/source")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody( + matchingJsonPath("$.sources[0].kind", equalTo("http")) + .and(matchingJsonPath("$.sources[0].url", equalTo("https://docs.arconia.io/arconia-cli/latest/development/dev/"))) + .and(matchingJsonPath("$.target.kind", equalTo("put"))) + .and(matchingJsonPath("$.target.url", equalTo("https://github.com/docling-project/docling-java/save"))) + ) + ); + } + @Test void shouldConvertS3SourceSuccessfully() { // Need to use Wiremock here rather than a "real" backend because Docling Serve requires kubeflow @@ -528,7 +611,14 @@ void shouldConvertS3SourceSuccessfully() { .withRequestBody(equalToJson(writeValueAsString(request))) .withHeader("Content-Type", equalTo("application/json")) .withHeader("Accept", equalTo("application/json")) - .willReturn(okJson("{}")) + .willReturn(okJson(""" + { + "processing_time": 7, + "num_converted": 10, + "num_succeeded": 5, + "num_failed": 5 + } + """)) ); var response = getDoclingClient(false, true).convertSource(request); @@ -556,29 +646,32 @@ void shouldConvertS3SourceSuccessfully() { } @Test - void shouldConvertHttpSourceSuccessfully() { + void shouldConvertSingleHttpSourceWithDefaultTargetSuccessfully() { var request = ConvertDocumentRequest.builder() .source(HttpSource.builder().url(URI.create("https://docs.arconia.io/arconia-cli/latest/development/dev/")).build()) .build(); var response = getDoclingClient().convertSource(request); - assertConvertHttpSource(response); + assertConvertSingleHttpSourceWithDefaultTarget(response); } @Test void shouldConvertFileSuccessfully() { var response = getDoclingClient().convertFiles(Path.of("src", "test", "resources", "story.pdf")); - + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); - assertThat(response.getDocument().getFilename()).isEqualTo("story.pdf"); - if (response.getProcessingTime()!=null) { - assertThat(response.getProcessingTime()).isPositive(); + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); + assertThat(inBodyResponse.getDocument().getFilename()).isEqualTo("story.pdf"); + + if (inBodyResponse.getProcessingTime()!=null) { + assertThat(inBodyResponse.getProcessingTime()).isPositive(); } - assertThat(response.getDocument().getMarkdownContent()).isNotEmpty(); + assertThat(inBodyResponse.getDocument().getMarkdownContent()).isNotEmpty(); } @Test @@ -597,9 +690,13 @@ void shouldHandleConversionWithDifferentDocumentOptions() { var response = getDoclingClient().convertSource(request); + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); + + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); } @Test @@ -615,11 +712,15 @@ void shouldHandleResponseWithDoclingDocument() { ConvertDocumentResponse response = getDoclingClient().convertSource(request); + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); - DoclingDocument doclingDocument = response.getDocument().getJsonContent(); + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); + + DoclingDocument doclingDocument = inBodyResponse.getDocument().getJsonContent(); assertThat(doclingDocument).isNotNull(); assertThat(doclingDocument.getName()).isNotEmpty(); assertThat(doclingDocument.getTexts().get(0).getLabel()).isEqualTo(DocItemLabel.TITLE); @@ -633,21 +734,30 @@ void shouldConvertSourceAsync() { ConvertDocumentResponse response = getDoclingClient().convertSourceAsync(request).toCompletableFuture().join(); + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); + assertThat(response).isInstanceOf(InBodyConvertDocumentResponse.class); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); - assertThat(response.getDocument().getMarkdownContent()).isNotEmpty(); + + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); + assertThat(inBodyResponse.getDocument().getMarkdownContent()).isNotEmpty(); } @Test void shouldConvertFileAsync() { ConvertDocumentResponse response = getDoclingClient().convertFilesAsync(Path.of("src", "test", "resources", "story.pdf")).toCompletableFuture().join(); + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); - assertThat(response.getDocument().getFilename()).isEqualTo("story.pdf"); - assertThat(response.getDocument().getMarkdownContent()).isNotEmpty(); + + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); + assertThat(inBodyResponse.getDocument().getFilename()).isEqualTo("story.pdf"); + assertThat(inBodyResponse.getDocument().getMarkdownContent()).isNotEmpty(); } @Test @@ -666,9 +776,15 @@ void shouldHandleAsyncConversionWithDifferentDocumentOptions() { ConvertDocumentResponse response = getDoclingClient().convertSourceAsync(request).toCompletableFuture().join(); + assertThat(ResponseType.InBodyConvertDocumentResponse.equals(response.getResponseType())).isTrue(); assertThat(response).isNotNull(); - assertThat(response.getStatus()).isNotEmpty(); - assertThat(response.getDocument()).isNotNull(); + + var inBodyResponse = (InBodyConvertDocumentResponse)response; + + + assertThat(inBodyResponse).isNotNull(); + assertThat(inBodyResponse.getStatus()).isNotEmpty(); + assertThat(inBodyResponse.getDocument()).isNotNull(); } @Test @@ -679,12 +795,170 @@ void shouldChainAsyncOperations() { // Test chaining with thenApply String markdownContent = getDoclingClient().convertSourceAsync(request) - .thenApply(response -> response.getDocument().getMarkdownContent()) + .thenApply(response -> ((InBodyConvertDocumentResponse)response).getDocument().getMarkdownContent()) .toCompletableFuture().join(); assertThat(markdownContent).isNotEmpty(); } + @Test + void shouldConvertSingleFileSourceWithZipTargetAsync() { + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder() + .target(ZipTarget.builder().build()); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), Set.of("2408.09869.md")); + } + + @Test + void shouldConvertSingleFileSourceWithZipTargetAndReferencedImageExportModeAsync() { + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder() + .target(ZipTarget.builder().build()) + .options(ConvertDocumentOptions.builder().imageExportMode(ImageRefMode.REFERENCED).build()); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), + Set.of("2408.09869.md", + "artifacts/", + "artifacts/image_000000_4f05ea6de89ce20493a5d9cc2305a4feb948c7bb794d7b81ee29554ec56b8445.png")); + } + + @Test + void shouldConvertMultipleFileSourcesAsync(){ + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf"), + Path.of("src", "test", "resources", "story.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder(); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), + Set.of("2408.09869.md", "story.md")); + } + + @Test + void shouldConvertMultipleFileSourcesWithReferencedImageExportModeAsync(){ + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf"), + Path.of("src", "test", "resources", "story.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder() + .options(ConvertDocumentOptions.builder().imageExportMode(ImageRefMode.REFERENCED).build()); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), + Set.of("2408.09869.md", "story.md", "artifacts/", + "artifacts/image_000000_4f05ea6de89ce20493a5d9cc2305a4feb948c7bb794d7b81ee29554ec56b8445.png")); + } + + @Test + void shouldConvertMultipleFileSourcesWithInBodyTargetAsync() { + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf"), + Path.of("src", "test", "resources", "story.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder() + .target(ZipTarget.builder().build()); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), + Set.of("2408.09869.md", "story.md")); + } + + @Test + void shouldConvertMultipleFileSourcesWithInBodyTargetAndReferencedImageExportModeAsync() { + Path[] files = new Path[]{Path.of("src", "test", "resources", "2408.09869.pdf"), + Path.of("src", "test", "resources", "story.pdf")}; + + var requestBuilder = ConvertDocumentRequest + .builder() + .target(ZipTarget.builder().build()) + .options(ConvertDocumentOptions.builder().imageExportMode(ImageRefMode.REFERENCED).build()); + + FileUtils.createFileSources(files) + .forEach(requestBuilder::source); + + var request = requestBuilder.build(); + + var response = getDoclingClient() + .convertSourceAsync(request).toCompletableFuture().join(); + + assertThat(response).isNotNull(); + assertThat(response.getResponseType().equals(ResponseType.ZipArchiveConvertDocumentResponse)).isTrue(); + assertThat(response).isInstanceOf(ZipArchiveConvertDocumentResponse.class); + assertThat(((ZipArchiveConvertDocumentResponse)response).getFileName()).isEqualTo("converted_docs.zip"); + assertThat(((ZipArchiveConvertDocumentResponse)response).getInputStream()).isNotNull(); + assertZipArchiveEntries(((ZipArchiveConvertDocumentResponse)response).getInputStream(), + Set.of("2408.09869.md", "story.md", "artifacts/", + "artifacts/image_000000_4f05ea6de89ce20493a5d9cc2305a4feb948c7bb794d7b81ee29554ec56b8445.png")); + } + @Test void convertFilesNullFiles() { assertThatExceptionOfType(IllegalArgumentException.class) diff --git a/docling-serve/docling-serve-client/src/test/resources/2408.09869.pdf b/docling-serve/docling-serve-client/src/test/resources/2408.09869.pdf new file mode 100644 index 00000000..5c3267de Binary files /dev/null and b/docling-serve/docling-serve-client/src/test/resources/2408.09869.pdf differ diff --git a/docling-testing/docling-version-tests/src/main/java/ai/docling/client/tester/service/TagsTester.java b/docling-testing/docling-version-tests/src/main/java/ai/docling/client/tester/service/TagsTester.java index 2720975c..a53ed477 100644 --- a/docling-testing/docling-version-tests/src/main/java/ai/docling/client/tester/service/TagsTester.java +++ b/docling-testing/docling-version-tests/src/main/java/ai/docling/client/tester/service/TagsTester.java @@ -24,6 +24,8 @@ import ai.docling.serve.api.convert.request.source.HttpSource; import ai.docling.serve.api.convert.response.ConvertDocumentResponse; import ai.docling.serve.api.convert.response.DocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; +import ai.docling.serve.api.convert.response.PreSignedUrlConvertDocumentResponse; import ai.docling.serve.api.health.HealthCheckResponse; import ai.docling.testcontainers.serve.DoclingServeContainer; import ai.docling.testcontainers.serve.config.DoclingServeContainerConfig; @@ -109,57 +111,67 @@ private void doConversion(DoclingServeContainer doclingContainer) { } private void checkDoclingResponse(ConvertDocumentResponse response) { - Log.debugf("Response: %s", response); - - assertThat(response) - .as("Response should not be null") - .isNotNull(); - - assertThat(response.getStatus()) - .as("Response status should not be null or empty") - .isNotEmpty(); - - assertThat(response.getErrors()) - .as("Response should not have errors") - .isNullOrEmpty(); - - assertThat(response.getDocument()) - .as("Response should have a valid document") - .isNotNull() - .extracting( - DocumentResponse::getFilename, - DocumentResponse::getMarkdownContent, - DocumentResponse::getTextContent, - DocumentResponse::getJsonContent - ) - .satisfies(o -> - assertThat(o) - .as("Document should have a filename") - .asString() - .isNotEmpty(), - atIndex(0) - ) - .satisfies(o -> - assertThat(o) - .as("Document should have markdown content") - .asString() - .isNotEmpty(), - atIndex(1) - ) - .satisfies(o -> - assertThat(o) - .as("Document should have text content") - .asString() - .isNotEmpty(), - atIndex(2) - ) - .satisfies(o -> - assertThat(o) - .as("Document should have JSON content") - .asInstanceOf(InstanceOfAssertFactories.type(DoclingDocument.class)) - .isNotNull(), - atIndex(3) - ); + switch(response.getResponseType()) { + case InBodyConvertDocumentResponse -> { + var inBodyResponse = (InBodyConvertDocumentResponse)response; + Log.debugf("Response: %s", inBodyResponse); + + assertThat(inBodyResponse) + .as("Response should not be null") + .isNotNull(); + + assertThat(inBodyResponse.getStatus()) + .as("Response status should not be null or empty") + .isNotEmpty(); + + assertThat(inBodyResponse.getErrors()) + .as("Response should not have errors") + .isNullOrEmpty(); + + assertThat(inBodyResponse.getDocument()) + .as("Response should have a valid document") + .isNotNull() + .extracting( + DocumentResponse::getFilename, + DocumentResponse::getMarkdownContent, + DocumentResponse::getTextContent, + DocumentResponse::getJsonContent + ) + .satisfies(o -> + assertThat(o) + .as("Document should have a filename") + .asString() + .isNotEmpty(), + atIndex(0) + ) + .satisfies(o -> + assertThat(o) + .as("Document should have markdown content") + .asString() + .isNotEmpty(), + atIndex(1) + ) + .satisfies(o -> + assertThat(o) + .as("Document should have text content") + .asString() + .isNotEmpty(), + atIndex(2) + ) + .satisfies(o -> + assertThat(o) + .as("Document should have JSON content") + .asInstanceOf(InstanceOfAssertFactories.type(DoclingDocument.class)) + .isNotNull(), + atIndex(3) + ); + } + case PreSignedUrlConvertDocumentResponse -> { + var preSignedUrlResponse = (PreSignedUrlConvertDocumentResponse)response; + Log.debugf("Response: %s", preSignedUrlResponse); + } + case ZipArchiveConvertDocumentResponse -> {} + } } private void checkDoclingHealthy(DoclingServeApi doclingClient) { diff --git a/docs/src/doc/docs/docling-serve/serve-api.md b/docs/src/doc/docs/docling-serve/serve-api.md index ff97e14f..a8b4fcb0 100644 --- a/docs/src/doc/docs/docling-serve/serve-api.md +++ b/docs/src/doc/docs/docling-serve/serve-api.md @@ -59,7 +59,7 @@ import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; import ai.docling.serve.api.convert.request.options.OutputFormat; import ai.docling.serve.api.convert.request.source.HttpSource; import ai.docling.serve.api.convert.request.target.InBodyTarget; -import ai.docling.serve.api.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; DoclingServeApi api = DoclingServeApi.builder() .baseUrl("http://localhost:8000") // your Docling Serve URL @@ -78,7 +78,7 @@ ConvertDocumentRequest request = ConvertDocumentRequest.builder() .target(InBodyTarget.builder().build()) // get results in the HTTP response body .build(); -ConvertDocumentResponse response = api.convertSource(request); +InBodyConvertDocumentResponse response = (InBodyConvertDocumentResponse)api.convertSource(request); System.out.println(response.getDocument().getMarkdownContent()); ``` @@ -132,10 +132,12 @@ Options (`ai.docling.serve.api.convert.request.options.ConvertDocumentOptions`) Explore the `options` package for the full list of knobs you can turn. -### Responses: `ConvertDocumentResponse` and `DocumentResponse` - -- `ConvertDocumentResponse` contains the converted `document` (if any), `errors`, processing `status`, - total `processing_time`, and detailed `timings` map. +### Responses: `InBodyConvertDocumentResponse`, `PreSignedUrlConvertDocumentResponse`, `ZipArchiveConvertDocumentResponse` and `DocumentResponse` +- `InBodyConvertDocumentResponse` contains the converted `document` (if any), `errors`, processing `status`, + total `processing_time`, and detailed `timings` map. +- `PreSignedUrlConvertDocumentResponse` contains processing statistics - total `processing_time` and conversion metrics + `num_converted`, `num_succeeded`, `num_failed`. +- `ZipArchiveConvertDocumentResponse` contains `file_name` and an input stream for the archive. - `DocumentResponse` holds the actual content fields you requested, such as `md_content` (Markdown), `html_content`, `text_content`, and a `json_content` map. It also includes the `filename` and `doctags_content` when relevant. @@ -154,10 +156,10 @@ System.out.println("Service status: " + health.getStatus()); ## Error handling Conversion may succeed partially (e.g., some pages) while returning warnings or errors. Always inspect -`ConvertDocumentResponse#getErrors()` and consider `status`: +`InBodyConvertDocumentResponse#getErrors()` and consider `status`: ```java -ConvertDocumentResponse response = api.convertSource(request); +InBodyConvertDocumentResponse response = (InBodyConvertDocumentResponse)api.convertSource(request); if (response.getErrors() != null && !response.getErrors().isEmpty()) { response.getErrors().forEach(err -> diff --git a/docs/src/doc/docs/docling-serve/serve-client.md b/docs/src/doc/docs/docling-serve/serve-client.md index 952fd70c..112c833c 100644 --- a/docs/src/doc/docs/docling-serve/serve-client.md +++ b/docs/src/doc/docs/docling-serve/serve-client.md @@ -59,7 +59,7 @@ import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; import ai.docling.serve.api.convert.request.options.OutputFormat; import ai.docling.serve.api.convert.request.source.HttpSource; import ai.docling.serve.api.convert.request.target.InBodyTarget; -import ai.docling.serve.api.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; DoclingServeApi api = DoclingServeApi.builder() .baseUrl("http://localhost:8000") // your Docling Serve URL @@ -77,7 +77,7 @@ ConvertDocumentRequest request = ConvertDocumentRequest.builder() .target(InBodyTarget.builder().build()) .build(); -ConvertDocumentResponse response = api.convertSource(request); +InBodyConvertDocumentResponse response = (InBodyConvertDocumentResponse)api.convertSource(request); System.out.println(response.getDocument().getMarkdownContent()); ``` @@ -198,6 +198,7 @@ from `HttpClient`. Conversion may also return structured errors in the response `ConvertDocumentResponse#getErrors()` even when content is present: ```java +// InBodyConvertDocumentResponse var result = api.convertSource(request); if (result.getErrors() != null && !result.getErrors().isEmpty()) { result.getErrors().forEach(err -> diff --git a/docs/src/doc/docs/getting-started.md b/docs/src/doc/docs/getting-started.md index d895b206..65f046f6 100644 --- a/docs/src/doc/docs/getting-started.md +++ b/docs/src/doc/docs/getting-started.md @@ -6,7 +6,7 @@ Use the [`DoclingServeApi`](docling-serve/serve-api.md) to convert a document by 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.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; DoclingServeApi doclingServeApi = DoclingServeApi.builder() .baseUrl("") @@ -20,7 +20,7 @@ ConvertDocumentRequest request = ConvertDocumentRequest.builder() ) .build(); -ConvertDocumentResponse response = doclingServeApi.convertSource(request); +InBodyConvertDocumentResponse response = (InBodyConvertDocumentResponse)doclingServeApi.convertSource(request); System.out.println(response.getDocument().getMarkdownContent()); ``` diff --git a/docs/src/doc/docs/testcontainers.md b/docs/src/doc/docs/testcontainers.md index 7c3ef733..949553ac 100644 --- a/docs/src/doc/docs/testcontainers.md +++ b/docs/src/doc/docs/testcontainers.md @@ -95,7 +95,7 @@ import ai.docling.serve.api.convert.request.options.ConvertDocumentOptions; import ai.docling.serve.api.convert.request.options.OutputFormat; import ai.docling.serve.api.convert.request.source.HttpSource; import ai.docling.serve.api.convert.request.target.InBodyTarget; -import ai.docling.serve.api.convert.response.ConvertDocumentResponse; +import ai.docling.serve.api.convert.response.InBodyConvertDocumentResponse; String baseUrl = docling.getApiUrl(); DoclingServeApi api = DoclingServeApi.builder() @@ -111,7 +111,7 @@ ConvertDocumentRequest request = ConvertDocumentRequest.builder() .target(InBodyTarget.builder().build()) .build(); -ConvertDocumentResponse response = api.convertSource(request); +InBodyConvertDocumentResponse response = (InBodyConvertDocumentResponse)api.convertSource(request); // Assert on response.getDocument().getMarkdownContent(), errors, timings, etc. ```