From 27bc8185f4996f8d38be2b1d7adb1b1ed823961c Mon Sep 17 00:00:00 2001 From: Eric Deandrea Date: Mon, 15 Jun 2026 14:38:13 -0400 Subject: [PATCH] feat(api): add PresignedUrlTarget and PreSignedUrlConvertResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for the presigned-URL delivery mode introduced in docling-serve v1.22.0. Clients can now request `{"target": {"kind": "presigned_url"}}` and receive per-document artifact download URLs in a `PreSignedUrlConvertResponse`. New request target: - `PresignedUrlTarget` — marker class in the `Target` sealed hierarchy New response types: - `PreSignedUrlConvertResponse` — extends `ConvertDocumentResponse` with a `documents` list and aggregate conversion stats - `DocumentArtifactItem` — per-document result with status and artifacts - `ArtifactRef` — presigned download URL with expiry timestamp - Supporting enums: `ArtifactType`, `ConversionStatus`, `ProfilingScope` - Supporting type: `ProfilingItem` for per-stage timing data Additional changes: - Custom `Instant` serializers/deserializers for Jackson 2 and 3, following the existing `Duration` serializer pattern - Custom `ConvertDocumentResponse` deserializers for Jackson 2 and 3 that dispatch based on distinguishing JSON fields and fail fast on unknown shapes (replaces `@JsonTypeInfo(DEDUCTION)`) - `PreSignedUrlConvertDocumentResponse` gains `numPartiallySucceeded` field to align with the current OpenAPI spec - `ConvertOperations` treats `PresignedUrlTarget` as a remote target - Default Testcontainers image upgraded from v1.19.0 to v1.24.0 - WireMock functional tests verify full request/response round-trip - Documentation updated across all relevant files This work builds on the foundation laid by @dan-m62 in #535 and #537. Closes #541 Assisted-By: Claude Code Signed-off-by: Eric Deandrea --- README.md | 1 + .../request/target/PresignedUrlTarget.java | 33 ++++ .../api/convert/request/target/Target.java | 12 +- .../api/convert/response/ArtifactRef.java | 90 ++++++++++ .../api/convert/response/ArtifactType.java | 24 +++ .../convert/response/ConversionStatus.java | 24 +++ .../response/ConvertDocumentResponse.java | 20 +-- .../response/DocumentArtifactItem.java | 117 ++++++++++++ .../PreSignedUrlConvertDocumentResponse.java | 10 ++ .../response/PreSignedUrlConvertResponse.java | 116 ++++++++++++ .../api/convert/response/ProfilingItem.java | 92 ++++++++++ .../api/convert/response/ProfilingScope.java | 16 ++ .../api/convert/response/ResponseType.java | 17 +- ...n2ConvertDocumentResponseDeserializer.java | 49 ++++++ .../Jackson2InstantDeserializer.java | 17 ++ .../Jackson2InstantSerializer.java | 21 +++ ...n3ConvertDocumentResponseDeserializer.java | 41 +++++ .../Jackson3InstantDeserializer.java | 17 ++ .../Jackson3InstantSerializer.java | 21 +++ .../target/PresignedUrlTargetTests.java | 17 ++ .../convert/response/ArtifactRefTests.java | 60 +++++++ .../ConvertDocumentResponseTests.java | 50 ++++++ .../response/DocumentArtifactItemTests.java | 111 ++++++++++++ .../client/operations/ConvertOperations.java | 4 +- .../AbstractDoclingServeClientTests.java | 166 ++++++++++++++++++ .../config/DoclingServeContainerConfig.java | 2 +- docs/src/doc/docs/docling-serve/serve-api.md | 40 ++++- .../doc/docs/docling-serve/serve-client.md | 34 ++++ docs/src/doc/docs/getting-started.md | 1 + docs/src/doc/docs/whats-new.md | 8 + 30 files changed, 1209 insertions(+), 22 deletions(-) create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/request/target/PresignedUrlTarget.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactRef.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ArtifactType.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ConversionStatus.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/DocumentArtifactItem.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/PreSignedUrlConvertResponse.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingItem.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/convert/response/ProfilingScope.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2ConvertDocumentResponseDeserializer.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantDeserializer.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson2InstantSerializer.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3ConvertDocumentResponseDeserializer.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantDeserializer.java create mode 100644 docling-serve/docling-serve-api/src/main/java/ai/docling/serve/api/serialization/Jackson3InstantSerializer.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/request/target/PresignedUrlTargetTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/ArtifactRefTests.java create mode 100644 docling-serve/docling-serve-api/src/test/java/ai/docling/serve/api/convert/response/DocumentArtifactItemTests.java 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