diff --git a/README.md b/README.md
index 2a7ba2dc..0d4fc3f3 100644
--- a/README.md
+++ b/README.md
@@ -85,6 +85,7 @@ switch(result.getResponseType()) {
case ResponseType.IN_BODY -> // Response is InBodyConvertDocumentResponse
case ResponseType.ZIP_ARCHIVE -> // Response is ZipArchiveConvertDocumentResponse
case ResponseType.PRE_SIGNED_URL -> // Response is PreSignedUrlConvertDocumentResponse
+ case ResponseType.PRE_SIGNED_URL_RESPONSE -> // Response is PreSignedUrlConvertResponse (with per-document artifact download URLs)
}
```
More [usage information](https://docling-project.github.io/docling-java) is available in the docs.
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/PresignedUrlTarget.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/PresignedUrlTarget.java
new file mode 100644
index 00000000..762c8533
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/PresignedUrlTarget.java
@@ -0,0 +1,33 @@
+package ai.docling.serve.api.convert.request.target;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+/**
+ * Target for delivering the converted document via server-managed presigned URLs.
+ *
+ *
This is a concrete implementation of {@link Target}, where the {@code Kind} is set to
+ * {@link Kind#PRESIGNED_URL}. The docling-serve instance uploads each output artifact to its
+ * own configured object storage bucket and returns time-limited presigned GET URLs in the
+ * response.
+ *
+ *
Available in docling-serve {@code v1.22.0} and later.
+ *
+ *
Uses JSON serialization annotations to include only non-empty fields in the output.
+ *
+ *
This class overrides {@link Object#toString()} for a string representation of the instance.
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@tools.jackson.databind.annotation.JsonDeserialize(builder = PresignedUrlTarget.Builder.class)
+@lombok.extern.jackson.Jacksonized
+@lombok.Builder(toBuilder = true)
+@lombok.Getter
+@lombok.ToString
+public final class PresignedUrlTarget extends Target {
+
+ /**
+ * Builder for creating {@link PresignedUrlTarget} instances.
+ * Generated by Lombok's {@code @Builder} annotation.
+ */
+ @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/request/target/Target.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/Target.java
index bfdb5b28..6a1cce6c 100644
--- a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/Target.java
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/Target.java
@@ -10,9 +10,11 @@
* Represents an abstract target for defining where and how the converted document should be delivered.
*
*
The {@link Target} class is a sealed type that is extended by specific concrete implementations:
- * {@link InBodyTarget}, {@link PutTarget}, {@link S3Target}, and {@link ZipTarget}. These implementations specify different
- * delivery methods, such as including the document in the response body, sending it to a specified URI, or
- * zipping it for inclusion in the response.
+ * {@link InBodyTarget}, {@link PresignedUrlTarget}, {@link PutTarget}, {@link S3Target}, and
+ * {@link ZipTarget}. These implementations specify different delivery methods, such as
+ * including the document in the response body, sending it to a specified URI, zipping it for
+ * inclusion in the response, or returning server-managed presigned URLs that point at the
+ * docling-serve operator's object storage.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonTypeInfo(
@@ -22,15 +24,17 @@
)
@JsonSubTypes({
@Type(value = InBodyTarget.class, name = "inbody"),
+ @Type(value = PresignedUrlTarget.class, name = "presigned_url"),
@Type(value = PutTarget.class, name = "put"),
@Type(value = ZipTarget.class, name = "zip"),
@Type(value = S3Target.class, name = "s3")
})
@lombok.Getter
@lombok.ToString
-public abstract sealed class Target permits InBodyTarget, PutTarget, S3Target, ZipTarget {
+public abstract sealed class Target permits InBodyTarget, PresignedUrlTarget, PutTarget, S3Target, ZipTarget {
enum Kind {
@JsonProperty("inbody") INBODY,
+ @JsonProperty("presigned_url") PRESIGNED_URL,
@JsonProperty("put") PUT,
@JsonProperty("zip") ZIP,
@JsonProperty("s3") S3
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactRef.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactRef.java
new file mode 100644
index 00000000..9ca45823
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactRef.java
@@ -0,0 +1,90 @@
+package ai.docling.serve.api.convert.response;
+
+import java.net.URI;
+import java.time.Instant;
+
+import org.jspecify.annotations.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import ai.docling.serve.api.serialization.Jackson2InstantDeserializer;
+import ai.docling.serve.api.serialization.Jackson2InstantSerializer;
+import ai.docling.serve.api.serialization.Jackson3InstantDeserializer;
+import ai.docling.serve.api.serialization.Jackson3InstantSerializer;
+
+/**
+ * Represents a reference to a single output artifact returned as a presigned URL.
+ *
+ *
Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty
+ * fields are omitted from JSON output.
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@tools.jackson.databind.annotation.JsonDeserialize(builder = ArtifactRef.Builder.class)
+@lombok.extern.jackson.Jacksonized
+@lombok.Builder(toBuilder = true)
+@lombok.Getter
+@lombok.ToString
+public class ArtifactRef {
+
+ /**
+ * Export format of the artifact.
+ *
+ * @param artifactType the artifact type
+ * @return the artifact type
+ */
+ @JsonProperty("artifact_type")
+ @lombok.NonNull
+ private ArtifactType artifactType;
+
+ /**
+ * MIME type of the artifact content.
+ *
+ * @param mimeType the MIME type
+ * @return the MIME type
+ */
+ @JsonProperty("mime_type")
+ @lombok.NonNull
+ private String mimeType;
+
+ /**
+ * Presigned URL used to download the artifact.
+ *
+ * @param uri the presigned URL
+ * @return the presigned URL
+ */
+ @JsonProperty("uri")
+ @lombok.NonNull
+ private URI uri;
+
+ /**
+ * Instant at which the presigned URL signature stops being valid.
+ *
+ * @param urlExpiresAt the expiry timestamp
+ * @return the expiry timestamp
+ */
+ @JsonProperty("url_expires_at")
+ @JsonSerialize(using = Jackson2InstantSerializer.class)
+ @JsonDeserialize(using = Jackson2InstantDeserializer.class)
+ @tools.jackson.databind.annotation.JsonSerialize(using = Jackson3InstantSerializer.class)
+ @tools.jackson.databind.annotation.JsonDeserialize(using = Jackson3InstantDeserializer.class)
+ @Nullable
+ private Instant urlExpiresAt;
+
+ /**
+ * Builder for creating {@link ArtifactRef} instances.
+ * Generated by Lombok's {@code @Builder} annotation.
+ *
+ * Builder methods:
+ *
+ * - {@code artifactType(ArtifactType)} - Set the artifact type
+ * - {@code mimeType(String)} - Set the MIME type
+ * - {@code uri(URI)} - Set the presigned URL
+ * - {@code urlExpiresAt(Instant)} - Set the expiry timestamp
+ *
+ */
+ @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/ArtifactType.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactType.java
new file mode 100644
index 00000000..7610d031
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactType.java
@@ -0,0 +1,24 @@
+package ai.docling.serve.api.convert.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents the output format of a converted-document artifact.
+ *
+ *
+ * - {@code JSON}: Serialized {@code DoclingDocument} JSON.
+ * - {@code HTML}: HTML rendering of the document.
+ * - {@code MARKDOWN}: Markdown rendering of the document.
+ * - {@code TEXT}: Plain-text rendering of the document.
+ * - {@code DOCTAGS}: DocTags rendering of the document.
+ * - {@code RESOURCE_BUNDLE}: ZIP archive containing extracted images and supporting resources.
+ *
+ */
+public enum ArtifactType {
+ @JsonProperty("json") JSON,
+ @JsonProperty("html") HTML,
+ @JsonProperty("markdown") MARKDOWN,
+ @JsonProperty("text") TEXT,
+ @JsonProperty("doctags") DOCTAGS,
+ @JsonProperty("resource_bundle") RESOURCE_BUNDLE;
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConversionStatus.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConversionStatus.java
new file mode 100644
index 00000000..bce24692
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConversionStatus.java
@@ -0,0 +1,24 @@
+package ai.docling.serve.api.convert.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents the possible conversion outcomes for a document.
+ *
+ *
+ * - {@code PENDING}: Indicates that the conversion has not yet started.
+ * - {@code STARTED}: Indicates that the conversion is currently in progress.
+ * - {@code SUCCESS}: Indicates that all pages of the document were converted.
+ * - {@code PARTIAL_SUCCESS}: Indicates that some pages were converted and others failed.
+ * - {@code FAILURE}: Indicates that the document could not be converted.
+ * - {@code SKIPPED}: Indicates that the document was rejected at admission.
+ *
+ */
+public enum ConversionStatus {
+ @JsonProperty("pending") PENDING,
+ @JsonProperty("started") STARTED,
+ @JsonProperty("success") SUCCESS,
+ @JsonProperty("partial_success") PARTIAL_SUCCESS,
+ @JsonProperty("failure") FAILURE,
+ @JsonProperty("skipped") SKIPPED;
+}
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 8b7dc49a..58233051 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
@@ -2,26 +2,24 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
-import com.fasterxml.jackson.annotation.JsonSubTypes;
-import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+import ai.docling.serve.api.serialization.Jackson2ConvertDocumentResponseDeserializer;
+import ai.docling.serve.api.serialization.Jackson3ConvertDocumentResponseDeserializer;
/**
* Abstract response returned by the Convert API for a single conversion request.
*
+ * Deserialization uses explicit custom deserializers that dispatch to the correct
+ * concrete subtype based on distinguishing JSON fields.
+ *
* Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty
* collections/strings are omitted from JSON output.
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
-@JsonTypeInfo(
- use = JsonTypeInfo.Id.DEDUCTION
-)
-@JsonSubTypes({
- @JsonSubTypes.Type(InBodyConvertDocumentResponse.class),
- @JsonSubTypes.Type(PreSignedUrlConvertDocumentResponse.class),
- @JsonSubTypes.Type(ZipArchiveConvertDocumentResponse.class)
-})
+@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = Jackson2ConvertDocumentResponseDeserializer.class)
+@tools.jackson.databind.annotation.JsonDeserialize(using = Jackson3ConvertDocumentResponseDeserializer.class)
public abstract sealed class ConvertDocumentResponse permits InBodyConvertDocumentResponse, PreSignedUrlConvertDocumentResponse,
- ZipArchiveConvertDocumentResponse {
+ PreSignedUrlConvertResponse, ZipArchiveConvertDocumentResponse {
/**
* Type of response
*
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/DocumentArtifactItem.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/DocumentArtifactItem.java
new file mode 100644
index 00000000..36577d53
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/DocumentArtifactItem.java
@@ -0,0 +1,117 @@
+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;
+
+/**
+ * Represents the conversion outcome and artifact references for a single source document.
+ *
+ * Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty
+ * collections are omitted from JSON output.
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@tools.jackson.databind.annotation.JsonDeserialize(builder = DocumentArtifactItem.Builder.class)
+@lombok.extern.jackson.Jacksonized
+@lombok.Builder(toBuilder = true)
+@lombok.Getter
+@lombok.ToString
+public class DocumentArtifactItem {
+
+ /**
+ * Zero-based index of the source document within the originating task.
+ *
+ * @param sourceIndex the source index
+ * @return the source index
+ */
+ @JsonProperty("source_index")
+ private Integer sourceIndex;
+
+ /**
+ * Canonical identifier of the source document.
+ *
+ * @param sourceUri the source URI
+ * @return the source URI
+ */
+ @JsonProperty("source_uri")
+ @lombok.NonNull
+ private String sourceUri;
+
+ /**
+ * Filename used as the stem of each output artifact.
+ *
+ * @param filename the source filename
+ * @return the source filename
+ */
+ @JsonProperty("filename")
+ @lombok.NonNull
+ private String filename;
+
+ /**
+ * Terminal conversion outcome for this document.
+ *
+ * @param status the conversion status
+ * @return the conversion status
+ */
+ @JsonProperty("status")
+ @lombok.NonNull
+ private ConversionStatus status;
+
+ /**
+ * Errors encountered while converting this document.
+ *
+ * @param errors the list of errors
+ * @return the list of errors
+ */
+ @JsonProperty("errors")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @lombok.Singular
+ private List errors;
+
+ /**
+ * Per-stage timing breakdown keyed by stage name.
+ *
+ * @param timings the timings map
+ * @return the timings map
+ */
+ @JsonProperty("timings")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @lombok.Singular
+ private Map timings;
+
+ /**
+ * Presigned URLs for each requested output format.
+ *
+ * @param artifacts the list of artifact references
+ * @return the list of artifact references
+ */
+ @JsonProperty("artifacts")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @lombok.Singular
+ private List artifacts;
+
+ /**
+ * Builder for creating {@link DocumentArtifactItem} instances.
+ * Generated by Lombok's {@code @Builder} annotation.
+ *
+ * Builder methods:
+ *
+ * - {@code sourceIndex(Integer)} - Set the source index
+ * - {@code sourceUri(String)} - Set the source URI
+ * - {@code filename(String)} - Set the source filename
+ * - {@code status(ConversionStatus)} - Set the conversion status
+ * - {@code errors(List)} - Set the list of errors
+ * - {@code error(ErrorItem)} - Add a single error (use with @Singular)
+ * - {@code timings(Map)} - Set the timings map
+ * - {@code timing(String, ProfilingItem)} - Add a single timing entry (use with @Singular)
+ * - {@code artifacts(List)} - Set the list of artifact references
+ * - {@code artifact(ArtifactRef)} - Add a single artifact reference (use with @Singular)
+ *
+ */
+ @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
index 7f10c6d3..9c69ff45 100644
--- 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
@@ -60,6 +60,15 @@ public final class PreSignedUrlConvertDocumentResponse extends ConvertDocumentRe
@JsonProperty("num_succeeded")
private Integer numSucceeded;
+ /**
+ * Number of partial successes
+ *
+ * @param numPartiallySucceeded the number of partial successes
+ * @return the number of partial successes
+ */
+ @JsonProperty("num_partially_succeeded")
+ private Integer numPartiallySucceeded;
+
/**
* Number of failed conversions
*
@@ -84,6 +93,7 @@ public ResponseType getResponseType() {
* {@code processingTime(Double)} - Set the total processing time in seconds
* {@code numConverted(Integer)} - Set the number of attempted conversions
* {@code numSucceeded(Integer)} - Set the number of successful conversions
+ * {@code numPartiallySucceeded(Integer)} - Set the number of partial successes
* {@code numFailed(Integer)} - Set the number of failed conversions
*
*/
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertResponse.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertResponse.java
new file mode 100644
index 00000000..586e50be
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertResponse.java
@@ -0,0 +1,116 @@
+package ai.docling.serve.api.convert.response;
+
+import java.util.List;
+
+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 document conversions that target server-managed presigned URLs.
+ *
+ * This response type is returned when the conversion request specifies a
+ * {@link ai.docling.serve.api.convert.request.target.PresignedUrlTarget}. Each
+ * source document is represented by a {@link DocumentArtifactItem} in
+ * {@link #getDocuments() documents} which carries its conversion outcome and
+ * the list of presigned URLs the server produced for the requested output
+ * formats.
+ *
+ * Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty
+ * collections/strings are omitted from JSON output.
+ *
+ * @see ConvertDocumentResponse
+ * @see ResponseType#PRE_SIGNED_URL_RESPONSE
+ * @see ai.docling.serve.api.convert.request.target.PresignedUrlTarget
+ * @see DocumentArtifactItem
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@tools.jackson.databind.annotation.JsonDeserialize(builder = PreSignedUrlConvertResponse.Builder.class)
+@lombok.extern.jackson.Jacksonized
+@lombok.Builder(toBuilder = true)
+@lombok.Getter
+@lombok.ToString
+public final class PreSignedUrlConvertResponse 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 numConverted 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 partial successes.
+ *
+ * @param numPartiallySucceeded the number of partial successes
+ * @return the number of partial successes
+ */
+ @JsonProperty("num_partially_succeeded")
+ private Integer numPartiallySucceeded;
+
+ /**
+ * Number of failed conversions.
+ *
+ * @param numFailed the number of failed conversions
+ * @return the number of failed conversions
+ */
+ @JsonProperty("num_failed")
+ private Integer numFailed;
+
+ /**
+ * Per-source conversion outcomes and presigned artifact URLs.
+ *
+ * @param documents the list of per-source artifact items
+ * @return the list of per-source artifact items
+ */
+ @JsonProperty("documents")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @lombok.Singular
+ private List documents;
+
+ @Override
+ @lombok.ToString.Include
+ public ResponseType getResponseType() {
+ return ResponseType.PRE_SIGNED_URL_RESPONSE;
+ }
+
+ /**
+ * Builder for creating {@link PreSignedUrlConvertResponse} instances.
+ * Generated by Lombok's {@code @Builder} annotation.
+ *
+ * Builder methods:
+ *
+ * - {@code processingTime(Double)} - Set the total processing time in seconds
+ * - {@code numConverted(Integer)} - Set the number of attempted conversions
+ * - {@code numSucceeded(Integer)} - Set the number of successful conversions
+ * - {@code numPartiallySucceeded(Integer)} - Set the number of partial successes
+ * - {@code numFailed(Integer)} - Set the number of failed conversions
+ * - {@code documents(List)} - Set the list of per-source artifact items
+ * - {@code document(DocumentArtifactItem)} - Add a single per-source artifact item (use with @Singular)
+ *
+ */
+ @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/ProfilingItem.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingItem.java
new file mode 100644
index 00000000..2d371a6c
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingItem.java
@@ -0,0 +1,92 @@
+package ai.docling.serve.api.convert.response;
+
+import java.time.Instant;
+import java.util.List;
+
+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.databind.annotation.JsonDeserialize;
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+
+import ai.docling.serve.api.serialization.Jackson2InstantDeserializer;
+import ai.docling.serve.api.serialization.Jackson2InstantSerializer;
+import ai.docling.serve.api.serialization.Jackson3InstantDeserializer;
+import ai.docling.serve.api.serialization.Jackson3InstantSerializer;
+
+/**
+ * Represents per-stage timing measurements produced during a document conversion.
+ *
+ * Serialization uses {@link JsonInclude.Include#NON_EMPTY}, so nulls and empty
+ * collections are omitted from JSON output.
+ */
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+@tools.jackson.databind.annotation.JsonDeserialize(builder = ProfilingItem.Builder.class)
+@lombok.extern.jackson.Jacksonized
+@lombok.Builder(toBuilder = true)
+@lombok.Getter
+@lombok.ToString
+public class ProfilingItem {
+
+ /**
+ * Scope of the stage being measured.
+ *
+ * @param scope the profiling scope
+ * @return the profiling scope
+ */
+ @JsonProperty("scope")
+ private ProfilingScope scope;
+
+ /**
+ * Number of measurements recorded.
+ *
+ * @param count the measurement count
+ * @return the measurement count
+ */
+ @JsonProperty("count")
+ private Integer count;
+
+ /**
+ * Per-measurement durations in seconds.
+ *
+ * @param times the list of durations
+ * @return the list of durations
+ */
+ @JsonProperty("times")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @lombok.Singular
+ private List times;
+
+ /**
+ * Start timestamps for each measurement.
+ *
+ * @param startTimestamps the list of start timestamps
+ * @return the list of start timestamps
+ */
+ @JsonProperty("start_timestamps")
+ @JsonSetter(nulls = Nulls.AS_EMPTY)
+ @JsonSerialize(contentUsing = Jackson2InstantSerializer.class)
+ @JsonDeserialize(contentUsing = Jackson2InstantDeserializer.class)
+ @tools.jackson.databind.annotation.JsonSerialize(contentUsing = Jackson3InstantSerializer.class)
+ @tools.jackson.databind.annotation.JsonDeserialize(contentUsing = Jackson3InstantDeserializer.class)
+ @lombok.Singular
+ private List startTimestamps;
+
+ /**
+ * Builder for creating {@link ProfilingItem} instances.
+ * Generated by Lombok's {@code @Builder} annotation.
+ *
+ * Builder methods:
+ *
+ * - {@code scope(ProfilingScope)} - Set the profiling scope
+ * - {@code count(Integer)} - Set the measurement count
+ * - {@code times(List)} - Set the list of durations
+ * - {@code time(Double)} - Add a single duration (use with @Singular)
+ * - {@code startTimestamps(List)} - Set the list of start timestamps
+ * - {@code startTimestamp(Instant)} - Add a single start timestamp (use with @Singular)
+ *
+ */
+ @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/ProfilingScope.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingScope.java
new file mode 100644
index 00000000..a3e87630
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingScope.java
@@ -0,0 +1,16 @@
+package ai.docling.serve.api.convert.response;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Represents the scope of a profiling measurement.
+ *
+ *
+ * - {@code PAGE}: Indicates that the measurement is recorded per page.
+ * - {@code DOCUMENT}: Indicates that the measurement is recorded per document.
+ *
+ */
+public enum ProfilingScope {
+ @JsonProperty("page") PAGE,
+ @JsonProperty("document") DOCUMENT;
+}
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
index 608c2355..4e7ad254 100644
--- 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
@@ -13,15 +13,19 @@
* ({@link InBodyConvertDocumentResponse})
* {@link #ZIP_ARCHIVE} - Content is packaged and returned as a ZIP archive
* ({@link ZipArchiveConvertDocumentResponse})
- * {@link #PRE_SIGNED_URL} - Content is packaged as a ZIP archive and uploaded to the given target URI
- * and statistical information is returned.
+ * {@link #PRE_SIGNED_URL} - Content is uploaded to a client-supplied remote target and
+ * only aggregate processing statistics are returned.
* ({@link PreSignedUrlConvertDocumentResponse})
+ * {@link #PRE_SIGNED_URL_RESPONSE} - Each output artifact is uploaded to the server's
+ * configured object storage and returned as a time-limited presigned URL grouped by source document.
+ * ({@link PreSignedUrlConvertResponse})
*
*
* @see ConvertDocumentResponse
* @see InBodyConvertDocumentResponse
* @see ZipArchiveConvertDocumentResponse
* @see PreSignedUrlConvertDocumentResponse
+ * @see PreSignedUrlConvertResponse
*/
public enum ResponseType {
@@ -44,5 +48,12 @@ public enum ResponseType {
*
*/
@JsonProperty("presigned_url")
- PRE_SIGNED_URL
+ PRE_SIGNED_URL,
+
+ /**
+ * Represents response type - {@link PreSignedUrlConvertResponse}
+ *
+ */
+ @JsonProperty("presigned_url_response")
+ PRE_SIGNED_URL_RESPONSE
}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2ConvertDocumentResponseDeserializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2ConvertDocumentResponseDeserializer.java
new file mode 100644
index 00000000..1fcb2d99
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2ConvertDocumentResponseDeserializer.java
@@ -0,0 +1,49 @@
+package ai.docling.serve.api.serialization;
+
+import java.io.IOException;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+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.PreSignedUrlConvertResponse;
+import ai.docling.serve.api.convert.response.ZipArchiveConvertDocumentResponse;
+
+public class Jackson2ConvertDocumentResponseDeserializer extends JsonDeserializer {
+
+ @Override
+ public ConvertDocumentResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ var codec = p.getCodec();
+ if (!(codec instanceof ObjectMapper mapper)) {
+ throw JsonMappingException.from(p,
+ "Expected ObjectMapper codec for ConvertDocumentResponse deserialization");
+ }
+
+ JsonNode tree = mapper.readTree(p);
+
+ if (!(tree instanceof ObjectNode node)) {
+ throw JsonMappingException.from(p,
+ "Expected a JSON object for ConvertDocumentResponse but got " + tree.getNodeType());
+ }
+
+ if (node.has("documents")) {
+ return mapper.treeToValue(node, PreSignedUrlConvertResponse.class);
+ } else if (node.has("document")) {
+ return mapper.treeToValue(node, InBodyConvertDocumentResponse.class);
+ } else if (node.has("file_name")) {
+ return mapper.treeToValue(node, ZipArchiveConvertDocumentResponse.class);
+ } else if (node.has("num_converted")) {
+ return mapper.treeToValue(node, PreSignedUrlConvertDocumentResponse.class);
+ }
+
+ throw JsonMappingException.from(p,
+ "Cannot determine ConvertDocumentResponse subtype: none of the expected fields (documents, document, file_name, num_converted) found in JSON");
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantDeserializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantDeserializer.java
new file mode 100644
index 00000000..23caaedf
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantDeserializer.java
@@ -0,0 +1,17 @@
+package ai.docling.serve.api.serialization;
+
+import java.io.IOException;
+import java.time.Instant;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+
+public class Jackson2InstantDeserializer extends JsonDeserializer {
+
+ @Override
+ public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
+ String text = p.getValueAsString();
+ return (text != null) ? Instant.parse(text) : null;
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantSerializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantSerializer.java
new file mode 100644
index 00000000..777c8268
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantSerializer.java
@@ -0,0 +1,21 @@
+package ai.docling.serve.api.serialization;
+
+import java.io.IOException;
+import java.time.Instant;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.SerializerProvider;
+
+public class Jackson2InstantSerializer extends JsonSerializer {
+
+ @Override
+ public void serialize(Instant value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
+ if (value == null) {
+ gen.writeNull();
+ return;
+ }
+
+ gen.writeString(value.toString());
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3ConvertDocumentResponseDeserializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3ConvertDocumentResponseDeserializer.java
new file mode 100644
index 00000000..fc152ee8
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3ConvertDocumentResponseDeserializer.java
@@ -0,0 +1,41 @@
+package ai.docling.serve.api.serialization;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonParser;
+import tools.jackson.databind.DatabindException;
+import tools.jackson.databind.DeserializationContext;
+import tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ValueDeserializer;
+import tools.jackson.databind.node.ObjectNode;
+
+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.PreSignedUrlConvertResponse;
+import ai.docling.serve.api.convert.response.ZipArchiveConvertDocumentResponse;
+
+public class Jackson3ConvertDocumentResponseDeserializer extends ValueDeserializer {
+
+ @Override
+ public ConvertDocumentResponse deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
+ JsonNode tree = ctxt.readTree(p);
+
+ if (!(tree instanceof ObjectNode node)) {
+ throw DatabindException.from(p,
+ "Expected a JSON object for ConvertDocumentResponse but got " + tree.getNodeType());
+ }
+
+ if (node.has("documents")) {
+ return ctxt.readTreeAsValue(node, PreSignedUrlConvertResponse.class);
+ } else if (node.has("document")) {
+ return ctxt.readTreeAsValue(node, InBodyConvertDocumentResponse.class);
+ } else if (node.has("file_name")) {
+ return ctxt.readTreeAsValue(node, ZipArchiveConvertDocumentResponse.class);
+ } else if (node.has("num_converted")) {
+ return ctxt.readTreeAsValue(node, PreSignedUrlConvertDocumentResponse.class);
+ }
+
+ throw DatabindException.from(p,
+ "Cannot determine ConvertDocumentResponse subtype: none of the expected fields (documents, document, file_name, num_converted) found in JSON");
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantDeserializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantDeserializer.java
new file mode 100644
index 00000000..47e35629
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantDeserializer.java
@@ -0,0 +1,17 @@
+package ai.docling.serve.api.serialization;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonParser;
+import tools.jackson.databind.DeserializationContext;
+import tools.jackson.databind.ValueDeserializer;
+
+import java.time.Instant;
+
+public class Jackson3InstantDeserializer extends ValueDeserializer {
+
+ @Override
+ public Instant deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException {
+ String text = p.getValueAsString();
+ return (text != null) ? Instant.parse(text) : null;
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantSerializer.java b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantSerializer.java
new file mode 100644
index 00000000..89108b0f
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantSerializer.java
@@ -0,0 +1,21 @@
+package ai.docling.serve.api.serialization;
+
+import tools.jackson.core.JacksonException;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ValueSerializer;
+
+import java.time.Instant;
+
+public class Jackson3InstantSerializer extends ValueSerializer {
+
+ @Override
+ public void serialize(Instant value, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
+ if (value == null) {
+ gen.writeNull();
+ return;
+ }
+
+ gen.writeString(value.toString());
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/target/PresignedUrlTargetTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/target/PresignedUrlTargetTests.java
new file mode 100644
index 00000000..db002dc3
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/target/PresignedUrlTargetTests.java
@@ -0,0 +1,17 @@
+package ai.docling.serve.api.convert.request.target;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class PresignedUrlTargetTests {
+
+ @Test
+ void whenToBuilderInvokedThenEqualInstanceProduced() {
+ PresignedUrlTarget target = PresignedUrlTarget.builder().build();
+
+ PresignedUrlTarget roundtripped = target.toBuilder().build();
+
+ assertThat(roundtripped).usingRecursiveComparison().isEqualTo(target);
+ }
+}
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ArtifactRefTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ArtifactRefTests.java
new file mode 100644
index 00000000..add672c6
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ArtifactRefTests.java
@@ -0,0 +1,60 @@
+package ai.docling.serve.api.convert.response;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+class ArtifactRefTests {
+
+ private static final ArtifactRef FULL_REF = ArtifactRef.builder()
+ .artifactType(ArtifactType.JSON)
+ .mimeType("application/json")
+ .uri(URI.create("https://example.com/doc.json"))
+ .urlExpiresAt(Instant.parse("2026-06-15T11:22:41Z"))
+ .build();
+
+ @Test
+ void whenBuiltWithAllFieldsThenGettersReturnSetValues() {
+ assertThat(FULL_REF.getArtifactType()).isEqualTo(ArtifactType.JSON);
+ assertThat(FULL_REF.getMimeType()).isEqualTo("application/json");
+ assertThat(FULL_REF.getUri()).isEqualTo(URI.create("https://example.com/doc.json"));
+ assertThat(FULL_REF.getUrlExpiresAt()).isEqualTo(Instant.parse("2026-06-15T11:22:41Z"));
+ }
+
+ @Test
+ void artifactTypeRequired() {
+ assertThatThrownBy(() -> FULL_REF.toBuilder().artifactType(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("artifactType");
+ }
+
+ @Test
+ void mimeTypeRequired() {
+ assertThatThrownBy(() -> FULL_REF.toBuilder().mimeType(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("mimeType");
+ }
+
+ @Test
+ void uriRequired() {
+ assertThatThrownBy(() -> FULL_REF.toBuilder().uri(null).build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("uri");
+ }
+
+ @Test
+ void urlExpiresAtIsNullable() {
+ ArtifactRef ref = FULL_REF.toBuilder().urlExpiresAt(null).build();
+ assertThat(ref.getUrlExpiresAt()).isNull();
+ }
+
+ @Test
+ void toBuilderRoundtripsToEqualInstance() {
+ ArtifactRef roundtripped = FULL_REF.toBuilder().build();
+ assertThat(roundtripped).usingRecursiveComparison().isEqualTo(FULL_REF);
+ }
+}
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 f6f95a73..77ffd6b9 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
@@ -87,6 +87,56 @@ void createPreSignedUrlConvertDocumentResponseWithNullFields() {
assertThat(response.getNumSucceeded()).isNull();
}
+ @Test
+ void createPreSignedUrlConvertResponseWithNullFields() {
+ PreSignedUrlConvertResponse response = PreSignedUrlConvertResponse.builder().build();
+
+ assertThat(response.getNumConverted()).isNull();
+ assertThat(response.getNumFailed()).isNull();
+ assertThat(response.getNumPartiallySucceeded()).isNull();
+ assertThat(response.getNumSucceeded()).isNull();
+ assertThat(response.getProcessingTime()).isNull();
+ assertThat(response.getDocuments()).isNotNull().isEmpty();
+ assertThat(response.getResponseType()).isEqualTo(ResponseType.PRE_SIGNED_URL_RESPONSE);
+ }
+
+ @Test
+ void createPreSignedUrlConvertResponseWithAllFields() {
+ ArtifactRef jsonArtifact = ArtifactRef.builder()
+ .artifactType(ArtifactType.JSON)
+ .mimeType("application/json")
+ .uri(java.net.URI.create("https://example.com/doc.json"))
+ .urlExpiresAt(java.time.Instant.parse("2026-06-15T11:22:41Z"))
+ .build();
+ DocumentArtifactItem document = DocumentArtifactItem.builder()
+ .sourceIndex(0)
+ .sourceUri("https://example.com/example.pdf")
+ .filename("example.pdf")
+ .status(ConversionStatus.SUCCESS)
+ .artifact(jsonArtifact)
+ .build();
+
+ PreSignedUrlConvertResponse response = PreSignedUrlConvertResponse.builder()
+ .processingTime(2.41)
+ .numConverted(1)
+ .numSucceeded(1)
+ .numPartiallySucceeded(0)
+ .numFailed(0)
+ .document(document)
+ .build();
+
+ assertThat(response.getProcessingTime()).isEqualTo(2.41);
+ assertThat(response.getNumConverted()).isEqualTo(1);
+ assertThat(response.getNumSucceeded()).isEqualTo(1);
+ assertThat(response.getNumPartiallySucceeded()).isZero();
+ assertThat(response.getNumFailed()).isZero();
+ assertThat(response.getDocuments()).hasSize(1);
+ assertThat(response.getDocuments().get(0).getStatus()).isEqualTo(ConversionStatus.SUCCESS);
+ assertThat(response.getDocuments().get(0).getArtifacts()).hasSize(1);
+ assertThat(response.getDocuments().get(0).getArtifacts().get(0).getArtifactType()).isEqualTo(ArtifactType.JSON);
+ assertThat(response.getResponseType()).isEqualTo(ResponseType.PRE_SIGNED_URL_RESPONSE);
+ }
+
@Test
void createResponseWithEmptyCollections() {
DocumentResponse document = DocumentResponse.builder()
diff --git a/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/DocumentArtifactItemTests.java b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/DocumentArtifactItemTests.java
new file mode 100644
index 00000000..4b456244
--- /dev/null
+++ b/docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/DocumentArtifactItemTests.java
@@ -0,0 +1,111 @@
+package ai.docling.serve.api.convert.response;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.URI;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+class DocumentArtifactItemTests {
+
+ @Test
+ void whenBuiltWithAllFieldsThenGettersReturnSetValues() {
+ ArtifactRef jsonRef = ArtifactRef.builder()
+ .artifactType(ArtifactType.JSON)
+ .mimeType("application/json")
+ .uri(URI.create("https://example.com/doc.json"))
+ .build();
+ ArtifactRef mdRef = ArtifactRef.builder()
+ .artifactType(ArtifactType.MARKDOWN)
+ .mimeType("text/markdown")
+ .uri(URI.create("https://example.com/doc.md"))
+ .build();
+ ErrorItem error = ErrorItem.builder()
+ .componentType("pipeline")
+ .errorMessage("page 3 malformed")
+ .moduleName("StandardPdfPipeline")
+ .build();
+
+ DocumentArtifactItem item = DocumentArtifactItem.builder()
+ .sourceIndex(0)
+ .sourceUri("https://example.com/example.pdf")
+ .filename("example.pdf")
+ .status(ConversionStatus.PARTIAL_SUCCESS)
+ .error(error)
+ .artifact(jsonRef)
+ .artifact(mdRef)
+ .build();
+
+ assertThat(item.getSourceIndex()).isZero();
+ assertThat(item.getSourceUri()).isEqualTo("https://example.com/example.pdf");
+ assertThat(item.getFilename()).isEqualTo("example.pdf");
+ assertThat(item.getStatus()).isEqualTo(ConversionStatus.PARTIAL_SUCCESS);
+ assertThat(item.getErrors()).containsExactly(error);
+ assertThat(item.getArtifacts()).containsExactly(jsonRef, mdRef);
+ assertThat(item.getTimings()).isNotNull().isEmpty();
+ }
+
+ @Test
+ void whenBuiltWithNullCollectionsThenGettersReturnEmpty() {
+ DocumentArtifactItem item = DocumentArtifactItem.builder()
+ .sourceIndex(0)
+ .sourceUri("https://example.com/example.pdf")
+ .filename("example.pdf")
+ .status(ConversionStatus.SUCCESS)
+ .build();
+
+ assertThat(item.getErrors()).isNotNull().isEmpty();
+ assertThat(item.getTimings()).isNotNull().isEmpty();
+ assertThat(item.getArtifacts()).isNotNull().isEmpty();
+ }
+
+ @Test
+ void sourceUriRequired() {
+ assertThatThrownBy(() -> DocumentArtifactItem.builder()
+ .filename("example.pdf")
+ .status(ConversionStatus.SUCCESS)
+ .sourceUri(null)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("sourceUri");
+ }
+
+ @Test
+ void filenameRequired() {
+ assertThatThrownBy(() -> DocumentArtifactItem.builder()
+ .sourceUri("https://example.com/example.pdf")
+ .status(ConversionStatus.SUCCESS)
+ .filename(null)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("filename");
+ }
+
+ @Test
+ void statusRequired() {
+ assertThatThrownBy(() -> DocumentArtifactItem.builder()
+ .sourceUri("https://example.com/example.pdf")
+ .filename("example.pdf")
+ .status(null)
+ .build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("status");
+ }
+
+ @Test
+ void errorsListIsImmutableAfterBuild() {
+ ErrorItem first = ErrorItem.builder().errorMessage("first").build();
+
+ DocumentArtifactItem item = DocumentArtifactItem.builder()
+ .sourceUri("https://example.com/example.pdf")
+ .filename("example.pdf")
+ .status(ConversionStatus.FAILURE)
+ .errors(List.of(first))
+ .build();
+
+ assertThat(item.getErrors()).containsExactly(first);
+ assertThat(item.getErrors()).isUnmodifiable();
+ }
+}
diff --git a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/ConvertOperations.java b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/ConvertOperations.java
index 8b7db4c3..7b96fb54 100644
--- a/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/ConvertOperations.java
+++ b/docling-serve/docling-serve-client/src/main/java/ai/docling/serve/client/operations/ConvertOperations.java
@@ -6,6 +6,7 @@
import ai.docling.serve.api.DoclingServeConvertApi;
import ai.docling.serve.api.DoclingServeTaskApi;
import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
+import ai.docling.serve.api.convert.request.target.PresignedUrlTarget;
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;
@@ -50,7 +51,8 @@ public ConvertDocumentResponse convertSource(ConvertDocumentRequest request) {
boolean hasMultipleSources = !Utils.isNullOrEmpty(request.getSources()) ?
request.getSources().size() > 1: Boolean.FALSE;
- boolean isRemoteTarget = request.getTarget() instanceof S3Target || request.getTarget() instanceof PutTarget;
+ boolean isRemoteTarget = request.getTarget() instanceof S3Target || request.getTarget() instanceof PutTarget
+ || request.getTarget() instanceof PresignedUrlTarget;
boolean isZipTarget = request.getTarget() instanceof ZipTarget;
if((hasMultipleSources && !isRemoteTarget) || isZipTarget) {
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 bbeb7a0c..78713036 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
@@ -73,12 +73,16 @@
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.PresignedUrlTarget;
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.ArtifactType;
+import ai.docling.serve.api.convert.response.ConversionStatus;
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.PreSignedUrlConvertResponse;
import ai.docling.serve.api.convert.response.ResponseType;
import ai.docling.serve.api.convert.response.ZipArchiveConvertDocumentResponse;
import ai.docling.serve.api.health.HealthCheckResponse;
@@ -645,6 +649,168 @@ void shouldConvertS3SourceSuccessfully() {
);
}
+ @Test
+ void shouldConvertSourceWithPresignedUrlTargetSuccessfully() {
+ var request = ConvertDocumentRequest.builder()
+ .source(
+ HttpSource
+ .builder()
+ .url(URI.create("https://arxiv.org/pdf/2408.09869"))
+ .build()
+ )
+ .target(
+ PresignedUrlTarget.builder().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": 4.13,
+ "num_converted": 1,
+ "num_succeeded": 1,
+ "num_partially_succeeded": 0,
+ "num_failed": 0,
+ "documents": [
+ {
+ "source_index": 0,
+ "source_uri": "https://arxiv.org/pdf/2408.09869",
+ "filename": "2408.09869",
+ "status": "success",
+ "errors": [],
+ "timings": {},
+ "artifacts": [
+ {
+ "artifact_type": "markdown",
+ "mime_type": "text/markdown",
+ "uri": "https://storage.example.com/2408.09869.md",
+ "url_expires_at": "2026-06-15T12:00:00Z"
+ }
+ ]
+ }
+ ]
+ }
+ """))
+ );
+
+ var response = getDoclingClient(false, true).convertSource(request);
+ assertThat(response).isNotNull();
+ assertThat(response.getResponseType()).isEqualTo(ResponseType.PRE_SIGNED_URL_RESPONSE);
+ assertThat(response).isInstanceOf(PreSignedUrlConvertResponse.class);
+
+ var presignedResponse = (PreSignedUrlConvertResponse) response;
+ assertThat(presignedResponse.getProcessingTime()).isEqualTo(4.13);
+ assertThat(presignedResponse.getNumConverted()).isEqualTo(1);
+ assertThat(presignedResponse.getNumSucceeded()).isEqualTo(1);
+ assertThat(presignedResponse.getNumPartiallySucceeded()).isZero();
+ assertThat(presignedResponse.getNumFailed()).isZero();
+
+ assertThat(presignedResponse.getDocuments()).hasSize(1);
+ var doc = presignedResponse.getDocuments().get(0);
+ assertThat(doc.getSourceIndex()).isZero();
+ assertThat(doc.getSourceUri()).isEqualTo("https://arxiv.org/pdf/2408.09869");
+ assertThat(doc.getFilename()).isEqualTo("2408.09869");
+ assertThat(doc.getStatus()).isEqualTo(ConversionStatus.SUCCESS);
+ assertThat(doc.getErrors()).isEmpty();
+ assertThat(doc.getArtifacts()).hasSize(1);
+
+ var artifact = doc.getArtifacts().get(0);
+ assertThat(artifact.getArtifactType()).isEqualTo(ArtifactType.MARKDOWN);
+ assertThat(artifact.getMimeType()).isEqualTo("text/markdown");
+ assertThat(artifact.getUri()).isEqualTo(URI.create("https://storage.example.com/2408.09869.md"));
+ assertThat(artifact.getUrlExpiresAt()).isNotNull();
+
+ wireMockServer.verify(
+ 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://arxiv.org/pdf/2408.09869")))
+ .and(matchingJsonPath("$.target.kind", equalTo("presigned_url")))
+ )
+ );
+ }
+
+ @Test
+ void shouldConvertSourceWithPresignedUrlTargetAndMultipleDocuments() {
+ var request = ConvertDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://arxiv.org/pdf/2408.09869")).build())
+ .source(HttpSource.builder().url(URI.create("https://arxiv.org/pdf/2501.17887")).build())
+ .target(PresignedUrlTarget.builder().build())
+ .build();
+
+ var wireMockServer = getWiremockServer();
+
+ wireMockServer.stubFor(
+ post("/v1/convert/source")
+ .withHeader("Content-Type", equalTo("application/json"))
+ .withHeader("Accept", equalTo("application/json"))
+ .willReturn(okJson("""
+ {
+ "processing_time": 8.27,
+ "num_converted": 2,
+ "num_succeeded": 2,
+ "num_partially_succeeded": 0,
+ "num_failed": 0,
+ "documents": [
+ {
+ "source_index": 0,
+ "source_uri": "https://arxiv.org/pdf/2408.09869",
+ "filename": "2408.09869",
+ "status": "success",
+ "artifacts": [
+ {
+ "artifact_type": "markdown",
+ "mime_type": "text/markdown",
+ "uri": "https://storage.example.com/2408.09869.md"
+ }
+ ]
+ },
+ {
+ "source_index": 1,
+ "source_uri": "https://arxiv.org/pdf/2501.17887",
+ "filename": "2501.17887",
+ "status": "success",
+ "artifacts": [
+ {
+ "artifact_type": "markdown",
+ "mime_type": "text/markdown",
+ "uri": "https://storage.example.com/2501.17887.md"
+ }
+ ]
+ }
+ ]
+ }
+ """))
+ );
+
+ var response = getDoclingClient(false, true).convertSource(request);
+ assertThat(response).isNotNull();
+ assertThat(response.getResponseType()).isEqualTo(ResponseType.PRE_SIGNED_URL_RESPONSE);
+
+ var presignedResponse = (PreSignedUrlConvertResponse) response;
+ assertThat(presignedResponse.getNumConverted()).isEqualTo(2);
+ assertThat(presignedResponse.getNumSucceeded()).isEqualTo(2);
+ assertThat(presignedResponse.getDocuments()).hasSize(2);
+ assertThat(presignedResponse.getDocuments().get(0).getFilename()).isEqualTo("2408.09869");
+ assertThat(presignedResponse.getDocuments().get(1).getFilename()).isEqualTo("2501.17887");
+ assertThat(presignedResponse.getDocuments().get(1).getSourceIndex()).isEqualTo(1);
+
+ wireMockServer.verify(
+ postRequestedFor(urlPathEqualTo("/v1/convert/source"))
+ .withRequestBody(
+ matchingJsonPath("$.target.kind", equalTo("presigned_url"))
+ .and(matchingJsonPath("$.sources[0].kind", equalTo("http")))
+ .and(matchingJsonPath("$.sources[1].kind", equalTo("http")))
+ )
+ );
+ }
+
@Test
void shouldConvertSingleHttpSourceWithDefaultTargetSuccessfully() {
var request = ConvertDocumentRequest.builder()
diff --git a/docling-testcontainers/src/main/java/ai/docling/testcontainers/serve/config/DoclingServeContainerConfig.java b/docling-testcontainers/src/main/java/ai/docling/testcontainers/serve/config/DoclingServeContainerConfig.java
index e2c9b506..2633142d 100644
--- a/docling-testcontainers/src/main/java/ai/docling/testcontainers/serve/config/DoclingServeContainerConfig.java
+++ b/docling-testcontainers/src/main/java/ai/docling/testcontainers/serve/config/DoclingServeContainerConfig.java
@@ -28,7 +28,7 @@ public interface DoclingServeContainerConfig {
/**
* Represents the version identifier for the docling-serve container image.
*/
- String DOCLING_IMAGE_VERSION = "v1.19.0";
+ String DOCLING_IMAGE_VERSION = "v1.24.0";
/**
* Default image name
diff --git a/docs/src/doc/docs/docling-serve/serve-api.md b/docs/src/doc/docs/docling-serve/serve-api.md
index e9b5cb9a..ff1af365 100644
--- a/docs/src/doc/docs/docling-serve/serve-api.md
+++ b/docs/src/doc/docs/docling-serve/serve-api.md
@@ -117,6 +117,7 @@ Supported sources (`ai.docling.serve.api.convert.request.source`):
Targets (`ai.docling.serve.api.convert.request.target`):
- `InBodyTarget` — receive results directly in the API response body (default use case)
+- `PresignedUrlTarget` — the service uploads each output artifact to its configured object storage and returns time-limited presigned download URLs (requires docling-serve v1.22.0+)
- `PutTarget` — the service uploads converted content via HTTP PUT to a specified URI
- `ZipTarget` — receive a zipped result
- `S3Target` — upload converted content to an S3 bucket
@@ -133,16 +134,51 @@ Options (`ai.docling.serve.api.convert.request.options.ConvertDocumentOptions`)
Explore the `options` package for the full list of knobs you can turn.
-### Responses: `InBodyConvertDocumentResponse`, `PreSignedUrlConvertDocumentResponse`, `ZipArchiveConvertDocumentResponse` and `DocumentResponse`
+### Responses: `InBodyConvertDocumentResponse`, `PreSignedUrlConvertDocumentResponse`, `PreSignedUrlConvertResponse`, `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`.
+ `num_converted`, `num_succeeded`, `num_partially_succeeded`, `num_failed`.
+- `PreSignedUrlConvertResponse` is returned when using a `PresignedUrlTarget`. It contains per-document results in a
+ `documents` list, where each `DocumentArtifactItem` carries the conversion `status`, any `errors`, and a list of
+ `ArtifactRef` entries with presigned download URLs for each output format. It also includes the same aggregate
+ conversion metrics as `PreSignedUrlConvertDocumentResponse`.
- `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.
+#### Presigned URL target example
+
+When using a `PresignedUrlTarget`, the response contains per-document artifact download links:
+
+```java
+import java.net.URI;
+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.request.target.PresignedUrlTarget;
+import ai.docling.serve.api.convert.response.PreSignedUrlConvertResponse;
+
+DoclingServeApi api = DoclingServeApi.builder()
+ .baseUrl("http://localhost:8000")
+ .build();
+
+ConvertDocumentRequest request = ConvertDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://arxiv.org/pdf/2408.09869")).build())
+ .target(PresignedUrlTarget.builder().build())
+ .build();
+
+PreSignedUrlConvertResponse response = (PreSignedUrlConvertResponse) api.convertSource(request);
+System.out.println("Converted: " + response.getNumSucceeded() + "/" + response.getNumConverted());
+
+response.getDocuments().forEach(doc -> {
+ System.out.println(doc.getFilename() + " -> " + doc.getStatus());
+ doc.getArtifacts().forEach(artifact ->
+ System.out.println(" " + artifact.getArtifactType() + ": " + artifact.getUri()));
+});
+```
+
## Health checks
You can ping the service to check readiness and basic status:
diff --git a/docs/src/doc/docs/docling-serve/serve-client.md b/docs/src/doc/docs/docling-serve/serve-client.md
index 7db4f0e0..44d53d9f 100644
--- a/docs/src/doc/docs/docling-serve/serve-client.md
+++ b/docs/src/doc/docs/docling-serve/serve-client.md
@@ -189,6 +189,13 @@ All request/response types come from [`docling-serve-api`](serve-api.md). Common
var target = InBodyTarget.builder().build();
```
+- Receive results as presigned download URLs (requires docling-serve v1.22.0+)
+
+ ```java
+ import ai.docling.serve.api.convert.request.target.PresignedUrlTarget;
+ var presigned = PresignedUrlTarget.builder().build();
+ ```
+
- Upload results to your storage via HTTP PUT
```java
@@ -199,6 +206,33 @@ All request/response types come from [`docling-serve-api`](serve-api.md). Common
.build();
```
+## Handling presigned URL responses
+
+When using `PresignedUrlTarget`, the response is a `PreSignedUrlConvertResponse` containing per-document results
+with artifact download links:
+
+```java
+import java.net.URI;
+import ai.docling.serve.api.convert.request.ConvertDocumentRequest;
+import ai.docling.serve.api.convert.request.source.HttpSource;
+import ai.docling.serve.api.convert.request.target.PresignedUrlTarget;
+import ai.docling.serve.api.convert.response.PreSignedUrlConvertResponse;
+
+ConvertDocumentRequest request = ConvertDocumentRequest.builder()
+ .source(HttpSource.builder().url(URI.create("https://arxiv.org/pdf/2408.09869")).build())
+ .target(PresignedUrlTarget.builder().build())
+ .build();
+
+PreSignedUrlConvertResponse response = (PreSignedUrlConvertResponse) api.convertSource(request);
+
+response.getDocuments().forEach(doc -> {
+ System.out.println(doc.getFilename() + " [" + doc.getStatus() + "]");
+ doc.getArtifacts().forEach(artifact ->
+ System.out.println(" " + artifact.getArtifactType() + ": " + artifact.getUri()
+ + (artifact.getUrlExpiresAt() != null ? " (expires: " + artifact.getUrlExpiresAt() + ")" : "")));
+});
+```
+
## Error handling tips
Transport errors (DNS, TLS, connection reset, timeouts) are thrown as standard Java exceptions
diff --git a/docs/src/doc/docs/getting-started.md b/docs/src/doc/docs/getting-started.md
index e81001f3..2825a91c 100644
--- a/docs/src/doc/docs/getting-started.md
+++ b/docs/src/doc/docs/getting-started.md
@@ -38,6 +38,7 @@ switch(result.getResponseType()) {
case ResponseType.IN_BODY -> // Response is InBodyConvertDocumentResponse
case ResponseType.ZIP_ARCHIVE -> // Response is ZipArchiveConvertDocumentResponse
case ResponseType.PRE_SIGNED_URL -> // Response is PreSignedUrlConvertDocumentResponse
+ case ResponseType.PRE_SIGNED_URL_RESPONSE -> // Response is PreSignedUrlConvertResponse (with per-document artifact download URLs)
}
```
diff --git a/docs/src/doc/docs/whats-new.md b/docs/src/doc/docs/whats-new.md
index b440bbe2..e837ff59 100644
--- a/docs/src/doc/docs/whats-new.md
+++ b/docs/src/doc/docs/whats-new.md
@@ -6,6 +6,9 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a
### {{ gradle.project_version }}
+
+### 0.5.2
+
* **New `docling-bom` module** — A Maven BOM (`ai.docling:docling-bom`) is now published, allowing consumers to align all Docling Java module versions with a single import.
* **Codecov configuration fixes** — Fixed module path mappings, ignore rules, and added per-Java-version coverage flags for accurate coverage reporting across all modules.
@@ -22,6 +25,11 @@ Docling Java {{ gradle.project_version }} includes important breaking changes, a
### {{ gradle.project_version }}
+* **New `PresignedUrlTarget` request target** — Request server-managed presigned-URL delivery by setting `target` to `PresignedUrlTarget`. The docling-serve instance uploads each output artifact to its configured object storage and returns time-limited presigned download URLs in the response. Requires docling-serve v1.22.0+.
+* **New `PreSignedUrlConvertResponse` response type** — Returned when using `PresignedUrlTarget`. Contains per-document results in a `documents` list, where each `DocumentArtifactItem` carries the conversion status and a list of `ArtifactRef` entries with presigned download URLs for each output format.
+* **New supporting types** — `DocumentArtifactItem`, `ArtifactRef`, `ArtifactType`, `ConversionStatus`, `ProfilingItem`, `ProfilingScope`.
+* Added `numPartiallySucceeded` field to `PreSignedUrlConvertDocumentResponse` to align with the current OpenAPI spec.
+* Upgraded default Testcontainers image from v1.19.0 to v1.24.0.
### 0.5.0