diff --git a/CHANGELOG.md b/CHANGELOG.md index b137456..c642d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to the Apify Java client are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-10-19 + +### Added + +- `SetRecordOptions.contentEncoding(String)`, matching the reference client's `setRecord` + `contentEncoding` option: upload a pre-compressed record value (e.g. a gzipped file) without the + client compressing it again. +- `DatasetClient.streamItems(DownloadItemsFormat, DatasetDownloadOptions)`, resolving to an + `InputStream` over the dataset export instead of fully buffering it, so a large export can be + processed in chunks without holding all of it in memory. +- `LogClient.getAsBytes()` / `getAsBytes(LogOptions)`, resolving to the log's raw bytes instead of + decoding them as text, matching the reference clients' `buffer`/`get_as_bytes` option. + ## [0.8.0] - 2026-10-12 ### Added diff --git a/README.md b/README.md index a8265fb..980125c 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Maven (Maven Central is a default repository, so no extra configuration is neede com.apify apify-client - 0.8.0 + 0.9.0 ``` @@ -35,7 +35,7 @@ repositories { } dependencies { - implementation 'com.apify:apify-client:0.8.0' + implementation 'com.apify:apify-client:0.9.0' } ``` @@ -203,7 +203,7 @@ non-blocking. `HttpRequest` and `HttpResponse` below are the JDK's `java.net. | Method | Signature | Contract | |---|---|---| | `sendAsync` | `CompletableFuture> sendAsync(HttpRequest request)` | Sends the single, fully-prepared request (auth, `User-Agent` and any other headers are already set by the client) and completes the future with the response with its body fully buffered as `byte[]`. Used for every non-streaming call. | -| `sendStreamingAsync` | `CompletableFuture> sendStreamingAsync(HttpRequest request)` | Sends the request and completes the future with the response body as a live `InputStream` for incremental consumption, used by log streaming (`LogClient.stream(...)`). The *caller* (the client) is responsible for closing the returned stream; an implementation must not close it itself before completing the future. | +| `sendStreamingAsync` | `CompletableFuture> sendStreamingAsync(HttpRequest request)` | Sends the request and completes the future with the response body as a live `InputStream` for incremental consumption, used by log streaming (`LogClient.stream(...)`) and dataset export streaming (`DatasetClient.streamItems(...)`). The *caller* (the client) is responsible for closing the returned stream; an implementation must not close it itself before completing the future. | Both methods perform exactly one network round-trip each — no retrying, no request mutation (retries, auth, and the `User-Agent` header are handled one layer up, by the client itself), and @@ -317,7 +317,7 @@ try { The public `com.apify.client.Version` class (`import com.apify.client.Version;`) exposes two constants: -- `Version.CLIENT_VERSION` — the semantic version of this client (`0.8.0`). +- `Version.CLIENT_VERSION` — the semantic version of this client (`0.9.0`). - `Version.API_SPEC_VERSION` — the version of the [Apify OpenAPI specification](https://docs.apify.com/api/openapi.json) (its `info.version` field) that this client's endpoints, parameters and models were last generated and checked against (`v2-2026-10-08T073338Z`). It is a snapshot, not a live compatibility diff --git a/docs/misc.md b/docs/misc.md index d45be9a..2d86256 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -80,6 +80,7 @@ Access a build's or run's log directly, or via `client.run(id).log()` / `client. | Method | Description | |---|---| | `get()` / `get(LogOptions)` | The whole log as text. Completes with `Optional` when addressed by an explicit id (`client.log(id)`); on `run.log()`/`build.log()` (no log id of its own), a 404 is ambiguous and throws `NotFoundError` instead — see [Fetching single resources](../README.md#fetching-single-resources). | +| `getAsBytes()` / `getAsBytes(LogOptions)` | As `get()`, but completes with `Optional` (the raw bytes, not decoded as text). Same 404 handling as `get()`. | | `stream()` / `stream(LogOptions)` | A live `InputStream` over the log (for redirection). Completes with `InputStream`; throws on any error response, including a 404. | `LogOptions` fields: `raw(Boolean)`, `download(Boolean)`. diff --git a/docs/storages.md b/docs/storages.md index 7440a18..f4d6802 100644 --- a/docs/storages.md +++ b/docs/storages.md @@ -44,6 +44,7 @@ any field not covered by a typed getter above is still available via the inherit | `iterateItems(DatasetListItemsOptions)` / `iterateItems(DatasetListItemsOptions, Long chunkSize)` | A lazy `Flow.Publisher` over all items; the options' `limit` caps the total yielded (`null`/unset or non-positive = all), the optional `chunkSize` sets the per-request page size (omitted/`null` = server default). | | `iterateItems(DatasetListItemsOptions, Long chunkSize, Class)` | As above, decoded into `T`. Returns `Flow.Publisher`. For typed iteration at the server-default page size, pass a `null` chunk size: `iterateItems(opts, null, T.class)`. | | `downloadItems(DownloadItemsFormat, DatasetDownloadOptions)` | Serialized bytes. `DownloadItemsFormat` is one of `JSON`, `JSONL`, `CSV`, `XLSX`, `XML`, `RSS`, `HTML`. Completes with `byte[]`. | +| `streamItems(DownloadItemsFormat, DatasetDownloadOptions)` | As above, but resolves to a live `InputStream` over the export instead of fully buffering it, so a large export is processed in chunks. The caller closes the stream (or reads it to the end). Throws on any error response, including a 404. | | `pushItems(Object)` | Push a single item or a list of items. Completes with no value (`CompletableFuture`). | | `getStatistics()` | Dataset statistics. Completes with `JsonNode`; throws `NotFoundError` if the dataset is gone (no separate "statistics absent" state). | | `createItemsPublicUrl(DatasetListItemsOptions, Long expiresInSecs)` | A public (optionally signed) items URL, served as `json`. Completes with `String`. | @@ -66,6 +67,15 @@ PaginationList page = client.dataset(ds.getId()).listItems(new Dataset byte[] csv = client.dataset(ds.getId()).downloadItems(DownloadItemsFormat.CSV, new DatasetDownloadOptions().bom(true)).join(); ``` +For a large export, `streamItems` avoids holding the whole serialized export in memory: + +```java +try (InputStream stream = + client.dataset("my-dataset").streamItems(DownloadItemsFormat.CSV, new DatasetDownloadOptions()).join()) { + stream.transferTo(System.out); +} +``` + `DatasetListItemsOptions` fields: `offset` (`Long`, number of items to skip), `limit` (`Long`, maximum number of items to return), `desc` (`Boolean`, return items newest-first), `fields` (`List`, restrict the output to these source fields), `outputFields` (`List`, @@ -78,7 +88,8 @@ becomes a separate item), `flatten` (`List`, flatten these nested fields keys), `view` (`String`, select a predefined dataset view for field selection), `simplified` (`Boolean`, return simplified — flattened and cleaned — items), `skipFailedPages` (`Boolean`, skip items that come from failed pages), `signature` (`String`, a pre-shared URL signature granting -access without an API token). `downloadItems(...)` returns `byte[]` (the serialized export). +access without an API token). `downloadItems(...)` returns `byte[]` (the serialized export); +`streamItems(...)` takes the same two arguments but returns a live `InputStream` over it instead. `DatasetDownloadOptions` wraps a `DatasetListItemsOptions` (`items(DatasetListItemsOptions)`) and adds `attachment` (`Boolean`), `bom` (`Boolean`), `delimiter` (`String`), `skipHeaderRow` (`Boolean`), `xmlRoot` (`String`), `xmlRow` (`String`), `feedTitle` (`String`), `feedDescription` @@ -116,7 +127,7 @@ datasets. | `recordExists(String key)` | Whether a record exists. Completes with `boolean`. | | `getRecord(String key)` / `getRecord(String key, GetRecordOptions)` | Fetch a record. Completes with `Optional`. | | `setRecord(String key, byte[] value, String contentType)` | Store raw bytes. Completes with no value (`CompletableFuture`). | -| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`SetRecordOptions`: `timeoutSecs` (`Long`), `doNotRetryTimeouts` (`Boolean`)). Completes with no value. | +| `setRecord(String key, byte[] value, String contentType, SetRecordOptions)` | Store raw bytes with write options (`SetRecordOptions`: `timeoutSecs` (`Long`), `doNotRetryTimeouts` (`Boolean`), `contentEncoding` (`String`, see below)). Completes with no value. | | `setRecordJson(String key, Object value)` | Store JSON. Completes with no value. | | `deleteRecord(String key)` | Delete a record. Completes with no value. | | `getRecordPublicUrl(String key)` | A public (optionally signed) record URL. Completes with `String`. | @@ -138,6 +149,27 @@ rec.ifPresent(r -> System.out.println(new String(r.getValue()))); `GetRecordOptions` (for `getRecord(String key, GetRecordOptions)`) fields: `attachment(Boolean)` (request a download disposition) and `signature(String)` (a pre-computed access signature). +`SetRecordOptions.contentEncoding(String)` names an encoding already applied to `value` (e.g. +`"gzip"` for a pre-compressed file), sent as the `Content-Encoding` header (trimmed first); the +client then sends the bytes as they are and does not compress them again, and the API accepts +`gzip`, `br`, `deflate`, or `identity` (matched case-insensitively — `identity` turns the client's +own automatic compression off for this upload). It must not be blank after trimming. The record is +stored exactly as uploaded, but since this client sends no `Accept-Encoding`, a later +`getRecord(...)` transparently receives the decompressed value rather than the stored compressed +bytes. + +```java +ByteArrayOutputStream buffer = new ByteArrayOutputStream(); +try (java.util.zip.GZIPOutputStream gzip = new java.util.zip.GZIPOutputStream(buffer)) { + gzip.write("{\"answer\":42}".getBytes()); +} +byte[] gzippedReport = buffer.toByteArray(); + +client.keyValueStore("my-store") + .setRecord("report.json", gzippedReport, "application/json", new SetRecordOptions().contentEncoding("gzip")) + .join(); +``` + `KeyValueStoreRecord` exposes `getKey()`, `getValue()` (raw bytes) and `getContentType()`. `KeyValueStoreKeysPage` exposes `getItems()` (a list of `KeyValueStoreKey` with `getKey()`/`getSize()`), `isTruncated()`, `getExclusiveStartKey()` and `getNextExclusiveStartKey()`. diff --git a/pom.xml b/pom.xml index e73f266..d6753b4 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.apify apify-client - 0.8.0 + 0.9.0 jar Apify Java Client diff --git a/src/main/java/com/apify/client/Version.java b/src/main/java/com/apify/client/Version.java index 6e09060..fe488ad 100644 --- a/src/main/java/com/apify/client/Version.java +++ b/src/main/java/com/apify/client/Version.java @@ -13,7 +13,7 @@ public final class Version { * The semantic version of this client library (see SemVer). * Changes to the public interface other than additive ones are considered breaking changes. */ - public static final String CLIENT_VERSION = "0.8.0"; + public static final String CLIENT_VERSION = "0.9.0"; /** * The version of the Apify OpenAPI specification this client was generated and verified against. diff --git a/src/main/java/com/apify/client/dataset/DatasetClient.java b/src/main/java/com/apify/client/dataset/DatasetClient.java index b63643d..3e35d6c 100644 --- a/src/main/java/com/apify/client/dataset/DatasetClient.java +++ b/src/main/java/com/apify/client/dataset/DatasetClient.java @@ -10,6 +10,7 @@ import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; import com.apify.client.internal.Signatures; +import java.io.InputStream; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -202,11 +203,32 @@ private CompletableFuture> fetchItemsPage( */ public CompletableFuture downloadItems( DownloadItemsFormat format, DatasetDownloadOptions options) { + String url = itemsUrl(format, options); + return http.call("GET", url, null, "", http.baseRequestTimeout()).thenApply(ApiResponse::body); + } + + /** + * As {@link #downloadItems}, but resolves to the response body as a live {@link InputStream} + * instead of fully buffering it, so a large export can be processed in chunks without holding all + * of it in memory. The caller is responsible for closing the returned stream (or reading it to + * the end). Like {@link com.apify.client.log.LogClient#stream}, this bypasses the + * buffered/retrying transport, so a non-2xx response is read to completion and thrown as an + * {@link com.apify.client.http.ApifyApiException} rather than retried. + */ + public CompletableFuture streamItems( + DownloadItemsFormat format, DatasetDownloadOptions options) { + return http.streamOrThrow(itemsUrl(format, options)); + } + + /** + * Builds the export URL shared by {@link #downloadItems} and {@link #streamItems}: the {@code + * items} sub-path with {@code format} and the given options applied as query params. + */ + private String itemsUrl(DownloadItemsFormat format, DatasetDownloadOptions options) { QueryParams params = new QueryParams(); params.addString("format", format.wireValue()); options.apply(params); - String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); - return http.call("GET", url, null, "", http.baseRequestTimeout()).thenApply(ApiResponse::body); + return ctx.mergedParams(params).applyToUrl(ctx.subUrl("items")); } /** diff --git a/src/main/java/com/apify/client/internal/HttpClientCore.java b/src/main/java/com/apify/client/internal/HttpClientCore.java index cd14bc9..4eaea99 100644 --- a/src/main/java/com/apify/client/internal/HttpClientCore.java +++ b/src/main/java/com/apify/client/internal/HttpClientCore.java @@ -64,9 +64,6 @@ public final class HttpClientCore { */ private static final int MIN_COMPRESS_BYTES = 1024; - /** Header announcing the request-body content coding to the server. */ - private static final String CONTENT_ENCODING_HEADER = "Content-Encoding"; - /** {@code Content-Encoding} value for brotli-compressed bodies (matches the reference client). */ private static final String ENCODING_BROTLI = "br"; @@ -190,7 +187,7 @@ public CompletableFuture call( Compressed compressed = compress(requestBody, BROTLI_AVAILABLE); requestBody = compressed.body(); headers = new LinkedHashMap<>(extraHeaders == null ? Map.of() : extraHeaders); - headers.put(CONTENT_ENCODING_HEADER, compressed.encoding()); + headers.put(HttpHeaders.CONTENT_ENCODING, compressed.encoding()); } String path = extractPath(url); @@ -372,7 +369,7 @@ private static boolean shouldCompress( } if (extraHeaders != null) { for (String key : extraHeaders.keySet()) { - if (CONTENT_ENCODING_HEADER.equalsIgnoreCase(key)) { + if (HttpHeaders.CONTENT_ENCODING.equalsIgnoreCase(key)) { return false; } } @@ -672,4 +669,37 @@ public CompletableFuture> streamAsync(String url) { }); return result; } + + /** + * As {@link #streamAsync(String)}, additionally inspecting the response itself: a non-2xx status + * is drained and thrown as the matching {@link ApifyApiException} subclass (single attempt - this + * bypasses the retrying transport, so the attempt number is always 1), while a success response + * completes with its live body stream. Shared by every streaming resource method (log streaming, + * dataset export streaming) so the drain-and-throw logic lives in one place. + */ + public CompletableFuture streamOrThrow(String url) { + return streamAsync(url) + .thenApply( + resp -> { + if (resp.statusCode() >= MAX_SUCCESS_STATUS) { + byte[] body = drainQuietly(resp.body()); + throw buildApiError(resp.statusCode(), body, 1, "GET", extractPath(url)); + } + return resp.body(); + }); + } + + /** + * Reads a stream to the end and closes it, for capturing a non-2xx streaming response's error + * body; a read failure is swallowed (the stream is being drained only to describe an error that + * already happened, so a second failure here is not worth surfacing over the original one) and + * yields an empty body instead. + */ + private static byte[] drainQuietly(InputStream in) { + try (InputStream stream = in) { + return stream.readAllBytes(); + } catch (IOException e) { + return new byte[0]; + } + } } diff --git a/src/main/java/com/apify/client/internal/HttpHeaders.java b/src/main/java/com/apify/client/internal/HttpHeaders.java index e07c5b9..552d4aa 100644 --- a/src/main/java/com/apify/client/internal/HttpHeaders.java +++ b/src/main/java/com/apify/client/internal/HttpHeaders.java @@ -16,5 +16,12 @@ public final class HttpHeaders { /** The request/response body media-type header name. */ public static final String CONTENT_TYPE = "Content-Type"; + /** + * The request/response body compression header name. Set explicitly on a key-value-store {@code + * setRecord} upload, it tells {@link HttpClientCore} the body is already encoded, which both + * skips the client's own automatic compression and forwards the caller's encoding verbatim. + */ + public static final String CONTENT_ENCODING = "Content-Encoding"; + private HttpHeaders() {} } diff --git a/src/main/java/com/apify/client/internal/ResourceContext.java b/src/main/java/com/apify/client/internal/ResourceContext.java index 484a4e9..c66c86e 100644 --- a/src/main/java/com/apify/client/internal/ResourceContext.java +++ b/src/main/java/com/apify/client/internal/ResourceContext.java @@ -483,8 +483,25 @@ public CompletableFuture putRaw( String contentType, Duration timeout, boolean doNotRetryTimeouts) { + return putRaw(subPath, params, body, contentType, null, timeout, doNotRetryTimeouts); + } + + /** + * As {@link #putRaw(String, QueryParams, byte[], String, Duration, boolean)}, additionally + * setting {@code extraHeaders} on the request (used to forward a caller-supplied {@code + * Content-Encoding} on a key-value-store record upload - see {@link HttpHeaders#CONTENT_ENCODING} + * - which also stops {@link HttpClientCore} from compressing the already-encoded body again). + */ + public CompletableFuture putRaw( + String subPath, + QueryParams params, + byte[] body, + String contentType, + Map extraHeaders, + Duration timeout, + boolean doNotRetryTimeouts) { String u = mergedParams(params).applyToUrl(subUrl(subPath)); - return http.call("PUT", u, body, contentType, timeout, doNotRetryTimeouts) + return http.call("PUT", u, body, contentType, extraHeaders, timeout, doNotRetryTimeouts) .thenApply(resp -> null); } diff --git a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java index 0084f3c..4b7e3fe 100644 --- a/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java +++ b/src/main/java/com/apify/client/keyvalue/KeyValueStoreClient.java @@ -10,6 +10,7 @@ import com.apify.client.internal.Signatures; import java.time.Duration; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Flow; @@ -308,10 +309,23 @@ public CompletableFuture setRecord(String key, byte[] value, String conten /** * Stores a record with raw bytes and the given content type, honoring the given write options - * ({@code timeoutSecs}, {@code doNotRetryTimeouts}). + * ({@code timeoutSecs}, {@code doNotRetryTimeouts}, {@code contentEncoding}). + * + * @throws IllegalArgumentException if {@code options.contentEncoding()} is set but blank */ public CompletableFuture setRecord( String key, byte[] value, String contentType, SetRecordOptions options) { + // Trimmed before validating/forwarding, matching the reference client's + // z.string().trim().min(1) schema (it sends the trimmed value, not the original). + String contentEncoding = options.contentEncodingValue(); + if (contentEncoding != null) { + contentEncoding = contentEncoding.trim(); + if (contentEncoding.isEmpty()) { + throw new IllegalArgumentException("contentEncoding must not be blank"); + } + } + Map extraHeaders = + contentEncoding == null ? null : Map.of(HttpHeaders.CONTENT_ENCODING, contentEncoding); Duration timeout = options.timeoutSecsValue() != null ? Duration.ofSeconds(options.timeoutSecsValue()) @@ -321,6 +335,7 @@ public CompletableFuture setRecord( new QueryParams(), value, contentType, + extraHeaders, timeout, options.doNotRetryTimeoutsValue()); } diff --git a/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java index 8f3b493..24891cd 100644 --- a/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java +++ b/src/main/java/com/apify/client/keyvalue/SetRecordOptions.java @@ -3,11 +3,12 @@ /** * Write options for {@link KeyValueStoreClient#setRecord(String, byte[], String, * SetRecordOptions)}, mirroring the reference client's {@code timeoutSecs}/{@code - * doNotRetryTimeouts}. + * doNotRetryTimeouts}/{@code contentEncoding}. */ public final class SetRecordOptions { private Long timeoutSecs; private Boolean doNotRetryTimeouts; + private String contentEncoding; /** * Per-request timeout for the upload, in seconds. Use it to shorten the wait for this upload; @@ -25,6 +26,26 @@ public SetRecordOptions doNotRetryTimeouts(Boolean doNotRetryTimeouts) { return this; } + /** + * Encoding already applied to the record's {@code value}, sent as the {@code Content-Encoding} + * header (trimmed first; it must not be blank). Set it to upload a pre-compressed value (e.g. a + * gzipped file read from disk): the client then sends the bytes as they are and does not compress + * them again. The API accepts {@code gzip}, {@code br}, {@code deflate}, and {@code identity} + * (matched case-insensitively) and stores the record exactly as uploaded; since this client does + * not advertise support for a compressed response ({@code Accept-Encoding}), a later {@link + * KeyValueStoreClient#getRecord(String) getRecord} transparently receives the decompressed value + * rather than the stored compressed bytes. Passing {@code identity} turns the client's own + * automatic compression off for this upload without changing how the client is configured. + * + *

Unlike the reference JS client, this has no corresponding type check: {@code value} here is + * always raw bytes (never a string or an object the client would serialize to JSON), so there is + * no non-binary case to reject. + */ + public SetRecordOptions contentEncoding(String contentEncoding) { + this.contentEncoding = contentEncoding; + return this; + } + Long timeoutSecsValue() { return timeoutSecs; } @@ -32,4 +53,8 @@ Long timeoutSecsValue() { boolean doNotRetryTimeoutsValue() { return Boolean.TRUE.equals(doNotRetryTimeouts); } + + String contentEncodingValue() { + return contentEncoding; + } } diff --git a/src/main/java/com/apify/client/log/LogClient.java b/src/main/java/com/apify/client/log/LogClient.java index d7bcced..7d5edae 100644 --- a/src/main/java/com/apify/client/log/LogClient.java +++ b/src/main/java/com/apify/client/log/LogClient.java @@ -4,7 +4,6 @@ import com.apify.client.internal.HttpClientCore; import com.apify.client.internal.QueryParams; import com.apify.client.internal.ResourceContext; -import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -58,6 +57,24 @@ public CompletableFuture> get(LogOptions options) { : Optional.of(new String(resp.body(), StandardCharsets.UTF_8))); } + /** + * As {@link #get()}, but resolves to the log's raw bytes instead of decoding them as text - + * mirroring the reference clients' {@code buffer}/{@code get_as_bytes} option. Useful when the + * log is not UTF-8 text, or the caller wants to avoid the decoding step. See {@link #get()} for + * 404 handling. + */ + public CompletableFuture> getAsBytes() { + return getAsBytes(new LogOptions()); + } + + /** As {@link #getAsBytes()}, with explicit options (raw, download). */ + public CompletableFuture> getAsBytes(LogOptions options) { + QueryParams params = new QueryParams(); + options.apply(params); + return ctx.getRawUnlessAmbiguous("", params) + .thenApply(resp -> resp == null ? Optional.empty() : Optional.of(resp.body())); + } + /** * Opens a live, streaming connection to the log and completes with a stream over the log bytes. * The caller is responsible for closing the returned {@link InputStream}. @@ -76,25 +93,6 @@ public CompletableFuture stream(LogOptions options) { params.addBool("stream", true); options.apply(params); String url = ctx.mergedParams(params).applyToUrl(ctx.subUrl("")); - - return ctx.http - .streamAsync(url) - .thenApply( - resp -> { - if (resp.statusCode() >= HttpClientCore.MAX_SUCCESS_STATUS) { - byte[] body = drain(resp.body()); - throw HttpClientCore.buildApiError( - resp.statusCode(), body, 1, "GET", HttpClientCore.extractPath(url)); - } - return resp.body(); - }); - } - - private static byte[] drain(InputStream in) { - try (InputStream stream = in) { - return stream.readAllBytes(); - } catch (IOException e) { - return new byte[0]; - } + return ctx.http.streamOrThrow(url); } } diff --git a/src/test/java/com/apify/client/AmbiguousNotFoundTest.java b/src/test/java/com/apify/client/AmbiguousNotFoundTest.java index 1fcaee9..5d8451a 100644 --- a/src/test/java/com/apify/client/AmbiguousNotFoundTest.java +++ b/src/test/java/com/apify/client/AmbiguousNotFoundTest.java @@ -59,6 +59,11 @@ void logByIdSwallows404ToEmpty() { assertTrue(client(404).log("missing").get().join().isEmpty()); } + @Test + void logGetAsBytesByIdSwallows404ToEmpty() { + assertTrue(client(404).log("missing").getAsBytes().join().isEmpty()); + } + // ---- Reached through a run/build with no id of its own: throw ------------------------- @Test diff --git a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java index 3233912..7d19607 100644 --- a/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java +++ b/src/test/java/com/apify/client/ClientBehaviourRegressionTest.java @@ -1,5 +1,6 @@ package com.apify.client; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -946,4 +947,93 @@ void createItemsPublicUrlWithoutFormatOmitsQueryParam() { .join(); assertFalse(url.contains("format="), url); } + + @Test + void setRecordForwardsContentEncodingAndSkipsDoubleCompression() { + MockTransport backend = MockTransport.ofConstant(200, ""); + // Larger than HttpClientCore's auto-compression threshold (1024 bytes) and trivially + // compressible, so sending it verbatim (rather than gzipping it again) is what proves the + // explicit header suppressed the client's own compression. + byte[] precompressed = "a".repeat(2000).getBytes(java.nio.charset.StandardCharsets.UTF_8); + client(backend) + .keyValueStore("s") + .setRecord( + "k", + precompressed, + "application/octet-stream", + new SetRecordOptions().contentEncoding("gzip")) + .join(); + assertEquals("gzip", backend.lastHeaders.firstValue("Content-Encoding").orElse(null)); + assertArrayEquals(precompressed, backend.lastBodyBytes); + } + + @Test + void setRecordRejectsBlankContentEncoding() { + MockTransport backend = MockTransport.ofConstant(200, ""); + assertThrows( + IllegalArgumentException.class, + () -> + client(backend) + .keyValueStore("s") + .setRecord( + "k", + new byte[] {1}, + "application/octet-stream", + new SetRecordOptions().contentEncoding(" "))); + assertEquals(0, backend.calls, "no request should be sent for a blank contentEncoding"); + } + + @Test + void setRecordTrimsContentEncodingBeforeForwarding() { + MockTransport backend = MockTransport.ofConstant(200, ""); + client(backend) + .keyValueStore("s") + .setRecord( + "k", + new byte[] {1}, + "application/octet-stream", + new SetRecordOptions().contentEncoding(" gzip ")) + .join(); + assertEquals( + "gzip", + backend.lastHeaders.firstValue("Content-Encoding").orElse(null), + "the header value must be trimmed, matching the reference client's schema"); + } + + @Test + void streamItemsResolvesToReadableStreamOfExport() throws java.io.IOException { + MockTransport backend = MockTransport.ofConstant(200, ""); + backend.scriptStream(200, "a;b\n1;2\n"); + java.io.InputStream stream = + TestAsync.await( + client(backend) + .dataset("d1") + .streamItems(DownloadItemsFormat.CSV, new DatasetDownloadOptions())); + byte[] bytes = stream.readAllBytes(); + assertEquals("a;b\n1;2\n", new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + assertTrue(backend.lastUrl.contains("format=csv"), backend.lastUrl); + } + + @Test + void streamItemsThrowsOnNonSuccessStatus() { + MockTransport backend = MockTransport.ofConstant(200, ""); + backend.scriptStream(404, "{\"error\":{\"type\":\"record-not-found\",\"message\":\"no\"}}"); + ApifyApiException ex = + assertThrows( + ApifyApiException.class, + () -> + TestAsync.await( + client(backend) + .dataset("d1") + .streamItems(DownloadItemsFormat.JSON, new DatasetDownloadOptions()))); + assertEquals(404, ex.getStatusCode()); + } + + @Test + void logGetAsBytesReturnsRawBytes() { + MockTransport backend = MockTransport.ofConstant(200, "log line 1\nlog line 2\n"); + byte[] bytes = client(backend).log("l1").getAsBytes().join().orElseThrow(); + assertEquals( + "log line 1\nlog line 2\n", new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + } } diff --git a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java index c4cf1a2..0f9a872 100644 --- a/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/ActorRunIntegrationTest.java @@ -61,6 +61,9 @@ void runActorAndReadOutputs() { Optional log = client.run(run.getId()).log().get().join(); assertTrue(log.isPresent() && !log.get().isEmpty()); + Optional logBytes = client.run(run.getId()).log().getAsBytes().join(); + assertTrue(logBytes.isPresent() && logBytes.get().length > 0); + client.run(run.getId()).dataset().listItems(new DatasetListItemsOptions()).join(); client.run(run.getId()).keyValueStore().getRecord("OUTPUT").join(); client.run(run.getId()).requestQueue().listHead(null).join(); diff --git a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java index 2414ffe..5a83cf4 100644 --- a/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/DatasetIntegrationTest.java @@ -11,6 +11,7 @@ import com.apify.client.dataset.DatasetDownloadOptions; import com.apify.client.dataset.DatasetListItemsOptions; import com.apify.client.dataset.DownloadItemsFormat; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; @@ -39,7 +40,7 @@ void getDataset() { } @Test - void datasetCrudFlow() { + void datasetCrudFlow() throws java.io.IOException { ApifyClient client = requireClient(); Dataset ds = client.datasets().getOrCreate(uniqueName("ds-crud")).join(); try { @@ -65,6 +66,13 @@ void datasetCrudFlow() { .join(); assertTrue(new String(csv, StandardCharsets.UTF_8).contains("url")); + byte[] streamed; + try (InputStream stream = + dataset.streamItems(DownloadItemsFormat.JSONL, new DatasetDownloadOptions()).join()) { + streamed = stream.readAllBytes(); + } + assertTrue(new String(streamed, StandardCharsets.UTF_8).contains("https://a.com")); + String url = dataset.createItemsPublicUrl(new DatasetListItemsOptions(), null).join(); assertTrue(url != null && !url.isEmpty()); diff --git a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java index b4417b4..7374e26 100644 --- a/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java +++ b/src/test/java/com/apify/client/integration/KeyValueStoreIntegrationTest.java @@ -10,6 +10,8 @@ import com.apify.client.keyvalue.KeyValueStoreClient; import com.apify.client.keyvalue.KeyValueStoreRecord; import com.apify.client.keyvalue.ListKeysOptions; +import com.apify.client.keyvalue.SetRecordOptions; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; import java.net.http.HttpClient; @@ -18,6 +20,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.Optional; +import java.util.zip.GZIPOutputStream; import org.junit.jupiter.api.Test; class KeyValueStoreIntegrationTest extends IntegrationBase { @@ -99,6 +102,42 @@ void keyValueStoreCrudFlow() throws Exception { } } + @Test + void setRecordWithContentEncodingStoresPrecompressedValueAsIs() throws IOException { + ApifyClient client = requireClient(); + KeyValueStore store = client.keyValueStores().getOrCreate(uniqueName("kvs-gzip")).join(); + try { + KeyValueStoreClient kvs = client.keyValueStore(store.getId()); + // Large and trivially compressible, so a double-gzip (the explicit header failing to + // suppress the client's own automatic compression) would silently corrupt it into different + // bytes rather than merely failing to shrink. + String original = "{\"hello\":\"world\",\"padding\":\"" + "x".repeat(4096) + "\"}"; + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(buffer)) { + gzip.write(original.getBytes(StandardCharsets.UTF_8)); + } + byte[] gzipped = buffer.toByteArray(); + + kvs.setRecord( + "report.json", + gzipped, + "application/json", + new SetRecordOptions().contentEncoding("gzip")) + .join(); + + // This client sends no `Accept-Encoding`, so the platform transparently decompresses the + // stored gzip record on the way back out - reading back the exact original plaintext proves + // the upload carried a single layer of gzip (the explicit header suppressed the client's own + // automatic compression); a double-gzip would leave one undecoded layer behind instead, + // failing this exact comparison. + Optional rec = kvs.getRecord("report.json").join(); + assertTrue(rec.isPresent()); + assertEquals(original, new String(rec.get().getValue(), StandardCharsets.UTF_8)); + } finally { + client.keyValueStore(store.getId()).delete().join(); + } + } + @Test void recordPublicUrlIsFetchable() throws IOException, InterruptedException { ApifyClient client = requireClient();