diff --git a/CHANGELOG.md b/CHANGELOG.md index aa787e7..e334c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ All notable changes to this project are documented here. The format follows ### Added +- `media().limits(workspaceId)` reads `GET /v1/media/limits` into `UploadLimits`: the account's + largest file by any route (100 MB up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15, + 5 GB beyond or with the Large Uploads add-on), the per-file multipart ceiling, the next tier, + and the allowed types. +- `posts().create(params, files)` and `posts().update(id, params, files)` attach `FileInput`s to + the first content block. Files within the multipart ceiling ride in one `multipart/form-data` + request (`post` JSON field plus `files` parts); larger ones are uploaded directly and attached + as media items before the usual JSON request. An update without new content reads the post + first and appends to its first block. +- `FileInput` describes a file to upload from a `Path`, an `InputStream` with a known size, or a + byte array. `media().uploadDirect(workspaceId, FileInput)` and `uploadDirect(PresignUploadParams, + FileInput)` stream it to the upload URL rather than holding it in memory. +- `PresignUploadParams` takes `folderId`, `origin`, `brandRole`, `brandVoiceId` and + `personConsent`; `PresignedUpload.maxFileBytes` reports the ceiling at reservation time. + +### Changed + +- Presign and direct upload accept sizes up to 5 GB; there is no 50 MB ceiling in the SDK. +- An oversize file is a `413` with code `file_too_large`. It surfaces as a `FoPostException` + with `status()` 413 and `max_file_bytes`, `file_bytes` and `upgrade_url` on `body()`. +- Multipart bodies and direct uploads stream from their source. `HttpRequestData` gained + `bodyStream` and `bodyLength` beside `body`; a custom `Transport` must send whichever is set. + - `InboxItem.moderationStatus` carries the platform's own state for a comment (`published`, `held`, `spam`, `rejected`), and `InboxAccount.reconnectRequired` flags an account connected before the inbox asked for a permission it needs. diff --git a/README.md b/README.md index 8a895a1..7740df0 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `webhooks()` | `list`, `create`, `update`, `delete`, `test` | | `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` | | `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` | -| `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | +| `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `passThreadControl`, `takeThreadControl`, `handover`, `listApprovals`, `approveReply`, `rejectReply` | | `contacts()` | `list`, `get`, `create`, `update`, `delete`, `conversations`, `importCsv`, `listFields`, `createField`, `updateField`, `updateFieldOptions`, `deleteField`, `conversationAnalytics` | @@ -178,18 +178,70 @@ client.posts().create(CreatePostParams.of(workspace.id()) ``` A direct upload sends the bytes to storage instead of through the API. `uploadDirect` does the -three steps in one call; `presign` and `complete` are the steps themselves, for a client that +three steps in one call and streams the file from a `Path` or an `InputStream`, so a multi-gigabyte +upload never sits in memory; `presign` and `complete` are the steps themselves, for a client that sends the bytes some other way: ```java -UploadedMedia file = client.media().uploadDirect(workspace.id(), "chart.png", "image/png", bytes); +UploadedMedia file = client.media().uploadDirect(workspace.id(), FileInput.of(Path.of("launch.mp4"))); PresignedUpload reserved = client.media().presign( - PresignUploadParams.of(workspace.id(), "chart.png", "image/png", bytes.length)); + PresignUploadParams.of(workspace.id(), "chart.png", "image/png", bytes.length) + .folderId(folderId) + .brandRole("logo")); // PUT bytes to reserved.uploadUrl() with reserved.headers(), then: UploadedMedia same = client.media().complete(reserved.uploadId()); ``` +`PresignUploadParams` also takes `origin("chat")`, `brandVoiceId`, and `personConsent(subject, +statement)`, which a `brandRole` of `person` requires. + +### Upload limits + +How large a file may be depends on the account: 100 MB with up to 2 connected accounts, 400 MB up +to 7, 2 GB up to 15, 5 GB beyond that or with the Large Uploads add-on. `limits` reports the +ceilings; `multipartMaxFileBytes` (100 MB) is the most `upload` or a post request carries per file, +and anything larger has to go through `uploadDirect`: + +```java +UploadLimits limits = client.media().limits(workspace.id()); +limits.maxFileBytes(); // the largest file by any route +limits.multipartMaxFileBytes(); // the per-file ceiling of a multipart request +limits.nextTier(); // what more connected accounts would unlock, or null +``` + +A file past the ceiling answers `413` with code `file_too_large`. It arrives as a `FoPostException` +whose `status()` is `413` and whose `body()` carries `max_file_bytes`, `file_bytes` and `upgrade_url`: + +```java +try { + client.media().upload(workspace.id(), Path.of("huge.mov")); +} catch (FoPostException e) { + if (e.status() == 413) { + long max = ((JsonNode) e.body()).path("max_file_bytes").asLong(); + } +} +``` + +### Files on posts + +`posts().create` and `posts().update` take a list of files and attach them to the first content +block, so there is no separate upload step: + +```java +Post post = client.posts().create( + CreatePostParams.of(workspace.id()).accounts(accountId).content("Launch day"), + List.of(FileInput.of(Path.of("launch.mp4")), FileInput.of("chart.png", "image/png", bytes))); + +client.posts().update(post.id(), UpdatePostParams.create(), List.of(FileInput.of(Path.of("more.png")))); +``` + +The SDK picks the route. When every file fits the multipart ceiling it sends one +`multipart/form-data` request to the post endpoint, with the JSON body in a `post` field and the +files under `files` (at most five). Otherwise it uploads each file directly, then sends the usual +JSON request with the results attached as media items. An update without new content reads the +post first and appends to what its first block already holds. + ## Webhooks The signing secret is returned by the create call and never shown again — store it then. diff --git a/src/main/java/com/fopost/sdk/internal/ApiClient.java b/src/main/java/com/fopost/sdk/internal/ApiClient.java index 57f16dc..47daeb0 100644 --- a/src/main/java/com/fopost/sdk/internal/ApiClient.java +++ b/src/main/java/com/fopost/sdk/internal/ApiClient.java @@ -11,6 +11,7 @@ import com.fopost.sdk.RateLimitException; import com.fopost.sdk.ValidationException; import java.io.IOException; +import java.io.InputStream; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -20,6 +21,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; /** * Internal HTTP layer: auth headers, query encoding, JSON coding, the {@code {"data": ...}} @@ -86,29 +88,31 @@ public JsonNode delete(String path) { /** Send a request and return the decoded body, retrying while the API answers 429. */ public JsonNode request(String method, String path, Object body, Map query) { - byte[] encoded = null; - String contentType = null; + Map headers = baseHeaders(); + String url = url(path, query); + HttpRequestData request; if (body instanceof Multipart multipart) { - contentType = multipart.contentType(); - encoded = multipart.build(); + headers.put("Content-Type", multipart.contentType()); + request = new HttpRequestData(method, url, headers, null, multipart::open, multipart.length()); } else if (body != null) { - contentType = "application/json"; - encoded = encode(body); + headers.put("Content-Type", "application/json"); + request = new HttpRequestData(method, url, headers, encode(body)); + } else { + request = new HttpRequestData(method, url, headers, null); } - return send(method, path, encoded, contentType, query); + return send(request); } - private JsonNode send(String method, String path, byte[] body, String contentType, Map query) { + private Map baseHeaders() { Map headers = new LinkedHashMap<>(); headers.put("Accept", "application/json"); headers.put("X-API-Key", apiKey); headers.put("User-Agent", USER_AGENT); - if (contentType != null) { - headers.put("Content-Type", contentType); - } + return headers; + } - String url = url(path, query); - HttpRequestData request = new HttpRequestData(method, url, headers, body); + private JsonNode send(HttpRequestData request) { + String url = request.url(); int attempt = 0; while (true) { @@ -137,8 +141,17 @@ private JsonNode send(String method, String path, byte[] body, String contentTyp * the PUT to a presigned upload URL, where the SDK's own headers would break the signature. */ public void sendRaw(String method, String url, Map headers, byte[] body) { - Map sent = headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers); - HttpRequestData request = new HttpRequestData(method, url, sent, body); + sendRaw(new HttpRequestData(method, url, copy(headers), body)); + } + + /** The streaming form of {@link #sendRaw(String, String, Map, byte[])}; {@code length} is exact. */ + public void sendRaw( + String method, String url, Map headers, Supplier body, long length) { + sendRaw(new HttpRequestData(method, url, copy(headers), null, body, length)); + } + + private void sendRaw(HttpRequestData request) { + String url = request.url(); HttpResponseData response; try { response = transport.send(request); @@ -312,6 +325,10 @@ static Duration retryAfter(HttpResponseData response) { } } + private static Map copy(Map headers) { + return headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers); + } + private static Duration min(Duration a, Duration b) { return a.compareTo(b) <= 0 ? a : b; } diff --git a/src/main/java/com/fopost/sdk/internal/HttpRequestData.java b/src/main/java/com/fopost/sdk/internal/HttpRequestData.java index 17ad375..bcbc34b 100644 --- a/src/main/java/com/fopost/sdk/internal/HttpRequestData.java +++ b/src/main/java/com/fopost/sdk/internal/HttpRequestData.java @@ -1,6 +1,28 @@ package com.fopost.sdk.internal; +import java.io.InputStream; import java.util.Map; +import java.util.function.Supplier; -/** One outbound HTTP call, as handed to a {@link Transport}. */ -public record HttpRequestData(String method, String url, Map headers, byte[] body) {} +/** + * One outbound HTTP call, as handed to a {@link Transport}. + * + *

The body is either {@code body} (in memory) or {@code bodyStream} with its exact + * {@code bodyLength}, so a large upload is never read into a byte array. At most one is set. + */ +public record HttpRequestData( + String method, + String url, + Map headers, + byte[] body, + Supplier bodyStream, + long bodyLength) { + + public HttpRequestData(String method, String url, Map headers, byte[] body) { + this(method, url, headers, body, null, body == null ? -1 : body.length); + } + + public boolean hasBody() { + return body != null || bodyStream != null; + } +} diff --git a/src/main/java/com/fopost/sdk/internal/JdkTransport.java b/src/main/java/com/fopost/sdk/internal/JdkTransport.java index c21eaa3..592a8cb 100644 --- a/src/main/java/com/fopost/sdk/internal/JdkTransport.java +++ b/src/main/java/com/fopost/sdk/internal/JdkTransport.java @@ -26,14 +26,10 @@ public JdkTransport(HttpClient client, Duration timeout) { @Override public HttpResponseData send(HttpRequestData request) throws IOException, InterruptedException { - HttpRequest.BodyPublisher publisher = request.body() == null - ? HttpRequest.BodyPublishers.noBody() - : HttpRequest.BodyPublishers.ofByteArray(request.body()); - HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(request.url())) .timeout(timeout) - .method(request.method(), publisher); + .method(request.method(), publisher(request)); request.headers().forEach(builder::header); HttpResponse response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray()); @@ -46,4 +42,16 @@ public HttpResponseData send(HttpRequestData request) throws IOException, Interr }); return new HttpResponseData(response.statusCode(), headers, response.body()); } + + private static HttpRequest.BodyPublisher publisher(HttpRequestData request) { + if (request.body() != null) { + return HttpRequest.BodyPublishers.ofByteArray(request.body()); + } + if (request.bodyStream() != null) { + // Fixed length so the request carries Content-Length, which a presigned PUT requires. + return HttpRequest.BodyPublishers.fromPublisher( + HttpRequest.BodyPublishers.ofInputStream(request.bodyStream()), request.bodyLength()); + } + return HttpRequest.BodyPublishers.noBody(); + } } diff --git a/src/main/java/com/fopost/sdk/internal/Multipart.java b/src/main/java/com/fopost/sdk/internal/Multipart.java index b5fad5c..afcfefe 100644 --- a/src/main/java/com/fopost/sdk/internal/Multipart.java +++ b/src/main/java/com/fopost/sdk/internal/Multipart.java @@ -1,16 +1,24 @@ package com.fopost.sdk.internal; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.UncheckedIOException; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.io.SequenceInputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.UUID; +import java.util.function.Supplier; -/** Builds a {@code multipart/form-data} body, for the two endpoints that take files. */ +/** + * Builds a {@code multipart/form-data} body for the endpoints that take files. File parts are + * streamed from their source when the request is sent, so the body is never held in memory whole. + */ public final class Multipart { private final String boundary = "----fopost" + UUID.randomUUID().toString().replace("-", ""); - private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + private final List> parts = new ArrayList<>(); + private long length; public String contentType() { return "multipart/form-data; boundary=" + boundary; @@ -20,36 +28,83 @@ public Multipart field(String name, String value) { if (value == null) { return this; } - write("--" + boundary + "\r\n"); - write("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"); - write(value); - write("\r\n"); + text("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + + value + "\r\n"); return this; } public Multipart file(String name, String filename, String contentType, byte[] content) { - write("--" + boundary + "\r\n"); - write("Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n"); - write("Content-Type: " + (contentType == null ? "application/octet-stream" : contentType) + "\r\n\r\n"); - writeBytes(content); - write("\r\n"); + return file(name, filename, contentType, () -> new ByteArrayInputStream(content), content.length); + } + + public Multipart file(String name, String filename, String contentType, Supplier content, long size) { + text("--" + boundary + "\r\n" + + "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: " + (contentType == null ? "application/octet-stream" : contentType) + "\r\n\r\n"); + parts.add(content); + length += size; + text("\r\n"); return this; } - public byte[] build() { - write("--" + boundary + "--\r\n"); - return out.toByteArray(); + /** Total body length including the closing boundary, for {@code Content-Length}. */ + public long length() { + return length + closing().length; + } + + /** The whole body as one stream; each file part is opened as the stream reaches it. */ + public InputStream open() { + List streams = new ArrayList<>(parts.size() + 1); + for (Supplier part : parts) { + streams.add(new LazyInputStream(part)); + } + streams.add(new ByteArrayInputStream(closing())); + return new SequenceInputStream(Collections.enumeration(streams)); } - private void write(String text) { - writeBytes(text.getBytes(StandardCharsets.UTF_8)); + private byte[] closing() { + return ("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8); } - private void writeBytes(byte[] bytes) { - try { - out.write(bytes); - } catch (IOException e) { - throw new UncheckedIOException(e); + private void text(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + parts.add(() -> new ByteArrayInputStream(bytes)); + length += bytes.length; + } + + /** Defers opening a file until the preceding parts have been consumed. */ + private static final class LazyInputStream extends InputStream { + + private final Supplier source; + private InputStream delegate; + + private LazyInputStream(Supplier source) { + this.source = source; + } + + private InputStream delegate() { + if (delegate == null) { + delegate = source.get(); + } + return delegate; + } + + @Override + public int read() throws java.io.IOException { + return delegate().read(); + } + + @Override + public int read(byte[] b, int off, int len) throws java.io.IOException { + return delegate().read(b, off, len); + } + + @Override + public void close() throws java.io.IOException { + if (delegate != null) { + delegate.close(); + } } } } diff --git a/src/main/java/com/fopost/sdk/model/PresignedUpload.java b/src/main/java/com/fopost/sdk/model/PresignedUpload.java index cf144d5..34b92a6 100644 --- a/src/main/java/com/fopost/sdk/model/PresignedUpload.java +++ b/src/main/java/com/fopost/sdk/model/PresignedUpload.java @@ -6,10 +6,12 @@ /** * A reserved direct upload. Send the raw bytes to {@code uploadUrl} with {@code method}, exactly * {@code headers}, and a {@code Content-Length} equal to the declared size, then complete it. + * {@code maxFileBytes} is the account's ceiling at the time of the reservation. */ public record PresignedUpload( String uploadId, String uploadUrl, String method, Map headers, - Instant expiresAt) {} + Instant expiresAt, + Long maxFileBytes) {} diff --git a/src/main/java/com/fopost/sdk/model/UploadLimits.java b/src/main/java/com/fopost/sdk/model/UploadLimits.java new file mode 100644 index 0000000..9d13902 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/UploadLimits.java @@ -0,0 +1,24 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * What the account may upload. {@code maxFileBytes} is the largest file by any route; + * {@code multipartMaxFileBytes} is the most one multipart request carries per file, so anything + * larger goes through a direct upload. {@code source} is {@code accounts}, {@code add_on} or + * {@code bypass}, and {@code nextTier} is the ceiling more connected accounts would unlock. + */ +public record UploadLimits( + Long maxFileBytes, + String source, + Integer connectedAccounts, + Boolean largeUploadsEnabled, + NextTier nextTier, + Long multipartMaxFileBytes, + Integer maxFilesPerUpload, + Boolean directUploadAvailable, + List allowedTypes) { + + /** {@code upToAccounts} is null on the top tier. */ + public record NextTier(Integer upToAccounts, Long maxFileBytes) {} +} diff --git a/src/main/java/com/fopost/sdk/param/FileInput.java b/src/main/java/com/fopost/sdk/param/FileInput.java new file mode 100644 index 0000000..edc1b40 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/FileInput.java @@ -0,0 +1,102 @@ +package com.fopost.sdk.param; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URLConnection; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.Supplier; + +/** + * A file to upload: its name, MIME type, exact size, and where the bytes come from. + * + *

{@code
+ * FileInput.of(Path.of("launch.mp4"));
+ * FileInput.of("chart.png", "image/png", bytes);
+ * FileInput.of("export.mov", "video/quicktime", inputStream, sizeInBytes);
+ * }
+ * + *

A {@link Path} is reopened for each request; an {@link InputStream} can be sent once, so a + * request that is retried after it has been read fails. + */ +public final class FileInput { + + private final String filename; + private final String mimeType; + private final long size; + private final Supplier source; + + private FileInput(String filename, String mimeType, long size, Supplier source) { + this.filename = filename; + this.mimeType = mimeType == null ? "application/octet-stream" : mimeType; + this.size = size; + this.source = source; + } + + public static FileInput of(Path path) { + return of(path, guessMimeType(path.getFileName().toString())); + } + + public static FileInput of(Path path, String mimeType) { + long size; + try { + size = Files.size(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new FileInput(path.getFileName().toString(), mimeType, size, () -> { + try { + return Files.newInputStream(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + public static FileInput of(String filename, String mimeType, byte[] content) { + return new FileInput(filename, mimeType, content.length, () -> new ByteArrayInputStream(content)); + } + + /** {@code size} must be the exact number of bytes the stream yields. */ + public static FileInput of(String filename, String mimeType, InputStream content, long size) { + boolean[] opened = new boolean[1]; + return new FileInput(filename, mimeType, size, () -> { + if (opened[0]) { + throw new IllegalStateException("fopost: an InputStream file can only be sent once"); + } + opened[0] = true; + return content; + }); + } + + public String filename() { + return filename; + } + + public String mimeType() { + return mimeType; + } + + public long size() { + return size; + } + + public Supplier source() { + return source; + } + + /** The content-block media type for this file: {@code gif}, {@code image} or {@code video}. */ + public String mediaType() { + if (mimeType.equalsIgnoreCase("image/gif")) { + return "gif"; + } + return mimeType.toLowerCase().startsWith("video/") ? "video" : "image"; + } + + static String guessMimeType(String filename) { + String guessed = URLConnection.guessContentTypeFromName(filename); + return guessed == null ? "application/octet-stream" : guessed; + } +} diff --git a/src/main/java/com/fopost/sdk/param/PresignUploadParams.java b/src/main/java/com/fopost/sdk/param/PresignUploadParams.java index 03c0a01..8a20f58 100644 --- a/src/main/java/com/fopost/sdk/param/PresignUploadParams.java +++ b/src/main/java/com/fopost/sdk/param/PresignUploadParams.java @@ -3,7 +3,16 @@ import java.util.LinkedHashMap; import java.util.Map; -/** The file a direct upload is being reserved for. {@code size} is the exact byte count that will be sent. */ +/** + * The file a direct upload is being reserved for. {@code size} is the exact byte count that will + * be sent, up to the account's {@code maxFileBytes}. + * + *

{@code
+ * PresignUploadParams.of(workspaceId, "logo.png", "image/png", size)
+ *     .brandRole("logo")
+ *     .brandVoiceId(brandId);
+ * }
+ */ public final class PresignUploadParams { private final Map body = new LinkedHashMap<>(); @@ -19,6 +28,40 @@ public static PresignUploadParams of(String workspaceId, String filename, String return params; } + public static PresignUploadParams of(String workspaceId, FileInput file) { + return of(workspaceId, file.filename(), file.mimeType(), file.size()); + } + + public PresignUploadParams folderId(String folderId) { + Params.put(body, "folderId", folderId); + return this; + } + + /** {@code chat} marks the file as produced in the agent chat. */ + public PresignUploadParams origin(String origin) { + Params.put(body, "origin", origin); + return this; + } + + /** {@code logo}, {@code product}, {@code person} or {@code background}. {@code person} needs {@link #personConsent}. */ + public PresignUploadParams brandRole(String brandRole) { + Params.put(body, "brandRole", brandRole); + return this; + } + + public PresignUploadParams brandVoiceId(String brandVoiceId) { + Params.put(body, "brandVoiceId", brandVoiceId); + return this; + } + + public PresignUploadParams personConsent(String subject, String statement) { + Map consent = new LinkedHashMap<>(); + consent.put("subject", subject); + consent.put("statement", statement); + body.put("personConsent", consent); + return this; + } + public Map toMap() { return new LinkedHashMap<>(body); } diff --git a/src/main/java/com/fopost/sdk/resource/MediaResource.java b/src/main/java/com/fopost/sdk/resource/MediaResource.java index 22c87f9..b21727a 100644 --- a/src/main/java/com/fopost/sdk/resource/MediaResource.java +++ b/src/main/java/com/fopost/sdk/resource/MediaResource.java @@ -4,18 +4,23 @@ import com.fopost.sdk.internal.Multipart; import com.fopost.sdk.model.MediaLibraryItem; import com.fopost.sdk.model.PresignedUpload; +import com.fopost.sdk.model.UploadLimits; import com.fopost.sdk.model.UploadedMedia; +import com.fopost.sdk.param.FileInput; import com.fopost.sdk.param.PresignUploadParams; -import java.io.IOException; -import java.io.UncheckedIOException; -import java.net.URLConnection; -import java.nio.file.Files; import java.nio.file.Path; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -/** The workspace media library. Upload once, then attach the returned url to a content block. */ +/** + * The workspace media library. Upload once, then attach the returned url to a content block. + * + *

A multipart {@link #upload} carries at most {@link UploadLimits#multipartMaxFileBytes()} per + * file; a larger file goes through {@link #uploadDirect}, up to {@link UploadLimits#maxFileBytes()}. + * Either answers {@code 413} with code {@code file_too_large} past the ceiling, and the body on + * the exception carries {@code max_file_bytes}. + */ public final class MediaResource { private final ApiClient http; @@ -30,11 +35,19 @@ public List list(String workspaceId) { return http.convertList(ApiClient.unwrap(http.get("/v1/media", query)), MediaLibraryItem.class); } - /** Upload one or more files. Each is checked against the plan's storage allowance. */ + /** The account's upload ceilings. {@code workspaceId} may be null. */ + public UploadLimits limits(String workspaceId) { + Map query = new LinkedHashMap<>(); + query.put("workspaceId", workspaceId); + return http.convert(ApiClient.unwrap(http.get("/v1/media/limits", query)), UploadLimits.class); + } + + /** Upload one or more files in one multipart request. Each is checked against the storage allowance. */ public List upload(String workspaceId, Path... files) { Multipart body = new Multipart(); for (Path file : files) { - body.file("files", file.getFileName().toString(), contentType(file), read(file)); + FileInput input = FileInput.of(file); + body.file("files", input.filename(), input.mimeType(), input.source(), input.size()); } body.field("workspaceId", workspaceId); return http.convertList( @@ -65,26 +78,23 @@ public UploadedMedia complete(String uploadId) { /** Presign, send the bytes to the upload URL with the returned headers, then complete. */ public UploadedMedia uploadDirect(String workspaceId, String filename, String mimeType, byte[] data) { - PresignedUpload presigned = presign(PresignUploadParams.of(workspaceId, filename, mimeType, data.length)); - http.sendRaw(presigned.method(), presigned.uploadUrl(), presigned.headers(), data); - return complete(presigned.uploadId()); + return uploadDirect(workspaceId, FileInput.of(filename, mimeType, data)); } - /** Removes the file from the library. Posts already published keep the copy on the platform. */ - public void delete(String mediaId) { - http.delete("/v1/media/" + mediaId); + /** The same three steps, streaming the file from its source rather than from memory. */ + public UploadedMedia uploadDirect(String workspaceId, FileInput file) { + return uploadDirect(PresignUploadParams.of(workspaceId, file), file); } - private static String contentType(Path file) { - String guessed = URLConnection.guessContentTypeFromName(file.getFileName().toString()); - return guessed == null ? "application/octet-stream" : guessed; + /** As above, with the reservation's optional fields (folder, brand role, consent) set on {@code params}. */ + public UploadedMedia uploadDirect(PresignUploadParams params, FileInput file) { + PresignedUpload presigned = presign(params); + http.sendRaw(presigned.method(), presigned.uploadUrl(), presigned.headers(), file.source(), file.size()); + return complete(presigned.uploadId()); } - private static byte[] read(Path path) { - try { - return Files.readAllBytes(path); - } catch (IOException e) { - throw new UncheckedIOException(e); - } + /** Removes the file from the library. Posts already published keep the copy on the platform. */ + public void delete(String mediaId) { + http.delete("/v1/media/" + mediaId); } } diff --git a/src/main/java/com/fopost/sdk/resource/PostsResource.java b/src/main/java/com/fopost/sdk/resource/PostsResource.java index 675afa0..c9f37b1 100644 --- a/src/main/java/com/fopost/sdk/resource/PostsResource.java +++ b/src/main/java/com/fopost/sdk/resource/PostsResource.java @@ -2,12 +2,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.internal.Json; import com.fopost.sdk.internal.Multipart; +import com.fopost.sdk.FoPostException; import com.fopost.sdk.model.BulkActionResult; import com.fopost.sdk.model.BulkImportResult; import com.fopost.sdk.model.BulkImportValidation; import com.fopost.sdk.model.CancelResult; +import com.fopost.sdk.model.ContentBlock; import com.fopost.sdk.model.Delivery; +import com.fopost.sdk.model.MediaItem; import com.fopost.sdk.model.DuplicatedPost; import com.fopost.sdk.model.Page; import com.fopost.sdk.model.PageMeta; @@ -17,7 +21,10 @@ import com.fopost.sdk.model.PublishResult; import com.fopost.sdk.model.PublishRun; import com.fopost.sdk.model.RetryResult; +import com.fopost.sdk.model.UploadLimits; +import com.fopost.sdk.model.UploadedMedia; import com.fopost.sdk.param.CreatePostParams; +import com.fopost.sdk.param.FileInput; import com.fopost.sdk.param.PostListParams; import com.fopost.sdk.param.PublishParams; import com.fopost.sdk.param.RetryParams; @@ -41,11 +48,14 @@ public final class PostsResource { private static final int DEFAULT_PER_PAGE = 30; + private static final long DEFAULT_MULTIPART_MAX_FILE_BYTES = 100L * 1024 * 1024; private final ApiClient http; + private final MediaResource media; public PostsResource(ApiClient http) { this.http = http; + this.media = new MediaResource(http); } // ─── Reading ────────────────────────────────────────────────────────────── @@ -88,11 +98,59 @@ public Post get(String postId) { // ─── Writing ────────────────────────────────────────────────────────────── public Post create(CreatePostParams params) { - return http.convert(ApiClient.unwrap(http.post("/v1/posts", params.toMap())), Post.class); + return create(params, List.of()); + } + + /** + * Create a post with files attached to its first content block. + * + *

Files within the account's multipart ceiling ride along in one multipart request; larger + * ones are uploaded directly to the library first. Either way they end up on the first block, + * and an oversize file answers {@code 413} with code {@code file_too_large}. + */ + public Post create(CreatePostParams params, List files) { + Map body = params.toMap(); + if (files == null || files.isEmpty()) { + return http.convert(ApiClient.unwrap(http.post("/v1/posts", body)), Post.class); + } + if (fitsMultipart(files, (String) body.get("workspace_id"))) { + return http.convert( + ApiClient.unwrap(http.request("POST", "/v1/posts", withFiles(body, files), null)), Post.class); + } + List> content = blocks(body.get("content")); + appendMedia(content, uploadAll((String) body.get("workspace_id"), files)); + body.put("content", content); + return http.convert(ApiClient.unwrap(http.post("/v1/posts", body)), Post.class); } public Post update(String postId, UpdatePostParams params) { - return http.convert(ApiClient.unwrap(http.put("/v1/posts/" + postId, params.toMap())), Post.class); + return update(postId, params, List.of()); + } + + /** + * Update a post and attach files to its first content block, by the same route rule as + * {@link #create(CreatePostParams, List)}. Without new content the post is read first so the + * files are appended to what is already there. + */ + public Post update(String postId, UpdatePostParams params, List files) { + Map body = params.toMap(); + if (files == null || files.isEmpty()) { + return http.convert(ApiClient.unwrap(http.put("/v1/posts/" + postId, body)), Post.class); + } + if (fitsMultipart(files, null)) { + return http.convert( + ApiClient.unwrap(http.request("PUT", "/v1/posts/" + postId, withFiles(body, files), null)), + Post.class); + } + Post existing = null; + if (!body.containsKey("content")) { + existing = get(postId); + } + List> content = + existing == null ? blocks(body.get("content")) : blocksOf(existing.content()); + appendMedia(content, uploadAll(existing == null ? null : existing.workspaceId(), files)); + body.put("content", content); + return http.convert(ApiClient.unwrap(http.put("/v1/posts/" + postId, body)), Post.class); } public void delete(String postId) { @@ -226,6 +284,112 @@ public void rollbackImport(String batchId) { http.delete("/v1/posts/bulk-import/" + batchId); } + // ─── Files on posts ─────────────────────────────────────────────────────── + + private boolean fitsMultipart(List files, String workspaceId) { + long ceiling = DEFAULT_MULTIPART_MAX_FILE_BYTES; + try { + UploadLimits limits = media.limits(workspaceId); + if (limits != null && limits.multipartMaxFileBytes() != null) { + ceiling = limits.multipartMaxFileBytes(); + } + } catch (FoPostException ignored) { + // The server enforces the ceiling either way; the default only picks the route. + } + for (FileInput file : files) { + if (file.size() > ceiling) { + return false; + } + } + return true; + } + + private static Multipart withFiles(Map body, List files) { + Multipart multipart; + try { + multipart = new Multipart().field("post", Json.MAPPER.writeValueAsString(body)); + } catch (IOException e) { + throw new FoPostException("fopost: could not encode the request body", e); + } + for (FileInput file : files) { + multipart.file("files", file.filename(), file.mimeType(), file.source(), file.size()); + } + return multipart; + } + + private List uploadAll(String workspaceId, List files) { + List items = new ArrayList<>(); + for (FileInput file : files) { + UploadedMedia uploaded = media.uploadDirect(workspaceId, file); + items.add(new MediaItem(file.mediaType(), uploaded.name(), uploaded.url(), uploaded.size(), null, null)); + } + return items; + } + + @SuppressWarnings("unchecked") + private static List> blocks(Object content) { + List> copy = new ArrayList<>(); + if (content instanceof List list) { + for (Object block : list) { + copy.add(new LinkedHashMap<>((Map) block)); + } + } + return copy; + } + + private static List> blocksOf(List content) { + List> copy = new ArrayList<>(); + if (content == null) { + return copy; + } + for (ContentBlock block : content) { + Map encoded = new LinkedHashMap<>(); + encoded.put("text", block.text() == null ? "" : block.text()); + if (block.media() != null && !block.media().isEmpty()) { + encoded.put("media", encode(block.media())); + } + copy.add(encoded); + } + return copy; + } + + @SuppressWarnings("unchecked") + private static void appendMedia(List> content, List items) { + if (content.isEmpty()) { + Map block = new LinkedHashMap<>(); + block.put("text", ""); + content.add(block); + } + Map first = content.get(0); + List> media = new ArrayList<>(); + if (first.get("media") instanceof List existing) { + media.addAll((List>) existing); + } + media.addAll(encode(items)); + first.put("media", media); + } + + private static List> encode(List items) { + List> encoded = new ArrayList<>(); + for (MediaItem item : items) { + Map entry = new LinkedHashMap<>(); + put(entry, "type", item.type()); + put(entry, "name", item.name()); + put(entry, "url", item.url()); + put(entry, "size", item.size()); + put(entry, "alt", item.alt()); + put(entry, "thumbnail", item.thumbnail()); + encoded.add(entry); + } + return encoded; + } + + private static void put(Map into, String key, Object value) { + if (value != null) { + into.put(key, value); + } + } + private static Multipart csvUpload(String filename, byte[] csv, String workspaceId) { return new Multipart().file("file", filename, "text/csv", csv).field("workspace_id", workspaceId); } diff --git a/src/test/java/com/fopost/sdk/FakeTransport.java b/src/test/java/com/fopost/sdk/FakeTransport.java index 5d8a8cd..56de8c0 100644 --- a/src/test/java/com/fopost/sdk/FakeTransport.java +++ b/src/test/java/com/fopost/sdk/FakeTransport.java @@ -3,6 +3,9 @@ import com.fopost.sdk.internal.HttpRequestData; import com.fopost.sdk.internal.HttpResponseData; import com.fopost.sdk.internal.Transport; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.util.ArrayDeque; import java.util.ArrayList; @@ -43,8 +46,21 @@ HttpRequestData last() { } String lastBody() { - byte[] body = last().body(); - return body == null ? "" : new String(body, StandardCharsets.UTF_8); + return new String(bodyOf(last()), StandardCharsets.UTF_8); + } + + static byte[] bodyOf(HttpRequestData request) { + if (request.body() != null) { + return request.body(); + } + if (request.bodyStream() == null) { + return new byte[0]; + } + try (InputStream in = request.bodyStream().get()) { + return in.readAllBytes(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } int callCount() { diff --git a/src/test/java/com/fopost/sdk/ResourcesTest.java b/src/test/java/com/fopost/sdk/ResourcesTest.java index 710e5f3..6d49290 100644 --- a/src/test/java/com/fopost/sdk/ResourcesTest.java +++ b/src/test/java/com/fopost/sdk/ResourcesTest.java @@ -134,7 +134,8 @@ void mediaUploadDirectPresignsPutsAndCompletes() { assertEquals("PUT", put.method()); assertEquals("https://bucket.test/u1?sig=abc", put.url()); assertEquals(Map.of("Content-Type", "image/png"), put.headers()); - assertArrayEquals(new byte[] {1, 2, 3}, put.body()); + assertArrayEquals(new byte[] {1, 2, 3}, FakeTransport.bodyOf(put)); + assertEquals(3L, put.bodyLength()); var complete = transport.requests.get(2); assertEquals("POST", complete.method()); diff --git a/src/test/java/com/fopost/sdk/UploadLimitsTest.java b/src/test/java/com/fopost/sdk/UploadLimitsTest.java new file mode 100644 index 0000000..efdcc65 --- /dev/null +++ b/src/test/java/com/fopost/sdk/UploadLimitsTest.java @@ -0,0 +1,234 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.HttpRequestData; +import com.fopost.sdk.model.Post; +import com.fopost.sdk.model.UploadLimits; +import com.fopost.sdk.param.CreatePostParams; +import com.fopost.sdk.param.FileInput; +import com.fopost.sdk.param.PresignUploadParams; +import com.fopost.sdk.param.UpdatePostParams; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class UploadLimitsTest { + + private static final String LIMITS = """ + {"data":{"maxFileBytes":2147483648,"source":"accounts","connectedAccounts":9, + "largeUploadsEnabled":false,"nextTier":{"upToAccounts":null,"maxFileBytes":5368709120}, + "multipartMaxFileBytes":104857600,"maxFilesPerUpload":5,"directUploadAvailable":true, + "allowedTypes":["image/png","video/mp4"]}}"""; + + @Test + void limitsReadsTheWorkspaceQueryAndTheTypedShape() { + FakeTransport transport = new FakeTransport().enqueue(200, LIMITS); + + UploadLimits limits = TestSupport.client(transport).media().limits("w1"); + + HttpRequestData request = transport.last(); + assertEquals("GET", request.method()); + assertEquals("https://api.fopost.test/v1/media/limits?workspaceId=w1", request.url()); + assertEquals(2147483648L, limits.maxFileBytes()); + assertEquals("accounts", limits.source()); + assertEquals(9, limits.connectedAccounts()); + assertFalse(limits.largeUploadsEnabled()); + assertNull(limits.nextTier().upToAccounts()); + assertEquals(5368709120L, limits.nextTier().maxFileBytes()); + assertEquals(104857600L, limits.multipartMaxFileBytes()); + assertEquals(5, limits.maxFilesPerUpload()); + assertTrue(limits.directUploadAvailable()); + assertEquals(List.of("image/png", "video/mp4"), limits.allowedTypes()); + } + + @Test + void limitsWithoutAWorkspaceSendsNoQuery() { + FakeTransport transport = new FakeTransport().enqueue(200, LIMITS); + + TestSupport.client(transport).media().limits(null); + + assertEquals("https://api.fopost.test/v1/media/limits", transport.last().url()); + } + + @Test + void presignCarriesTheOptionalFieldsAndALongSize() { + FakeTransport transport = new FakeTransport().enqueue(201, """ + {"data":{"uploadId":"u1","uploadUrl":"https://bucket.test/u1","method":"PUT", + "headers":{},"expiresAt":"2026-09-19T12:00:00Z","maxFileBytes":5368709120}}"""); + + var presigned = TestSupport.client(transport).media().presign( + PresignUploadParams.of("w1", "face.png", "image/png", 5368709120L) + .folderId("f1") + .origin("chat") + .brandRole("person") + .brandVoiceId("b1") + .personConsent("Jane", "Agreed on 2026-09-01")); + + String body = transport.lastBody(); + assertTrue(body.contains("\"size\":5368709120")); + assertTrue(body.contains("\"folderId\":\"f1\"")); + assertTrue(body.contains("\"origin\":\"chat\"")); + assertTrue(body.contains("\"brandRole\":\"person\"")); + assertTrue(body.contains("\"brandVoiceId\":\"b1\"")); + assertTrue(body.contains("\"personConsent\":{\"subject\":\"Jane\",\"statement\":\"Agreed on 2026-09-01\"}")); + assertEquals(5368709120L, presigned.maxFileBytes()); + } + + @Test + void uploadDirectStreamsTheFileWithItsLength() { + FakeTransport transport = new FakeTransport() + .enqueue(201, """ + {"data":{"uploadId":"u1","uploadUrl":"https://bucket.test/u1","method":"PUT", + "headers":{"Content-Type":"video/mp4"},"expiresAt":"2026-09-19T12:00:00Z"}}""") + .enqueue(200, "", Map.of()) + .enqueue(201, "{\"data\":{\"id\":\"m1\",\"type\":\"video\",\"name\":\"a.mp4\"," + + "\"url\":\"https://cdn.test/a.mp4\",\"size\":4}}"); + byte[] bytes = {9, 8, 7, 6}; + + TestSupport.client(transport).media().uploadDirect( + "w1", FileInput.of("a.mp4", "video/mp4", new ByteArrayInputStream(bytes), 4)); + + HttpRequestData put = transport.requests.get(1); + assertNull(put.body()); + assertEquals(4L, put.bodyLength()); + assertEquals(List.of(9, 8, 7, 6), bytesOf(FakeTransport.bodyOf(put))); + } + + @Test + void createWithSmallFilesSendsOneMultipartRequest() { + FakeTransport transport = new FakeTransport() + .enqueue(200, LIMITS) + .enqueue(201, "{\"id\":\"p1\",\"status\":\"draft\"}"); + + Post post = TestSupport.client(transport).posts().create( + CreatePostParams.of("w1").accounts("a1").content("With a picture"), + List.of(FileInput.of("chart.png", "image/png", new byte[] {1, 2, 3}))); + + assertEquals("p1", post.id()); + assertEquals(2, transport.callCount()); + assertEquals("https://api.fopost.test/v1/media/limits?workspaceId=w1", transport.requests.get(0).url()); + HttpRequestData create = transport.last(); + assertEquals("POST", create.method()); + assertEquals("https://api.fopost.test/v1/posts", create.url()); + assertTrue(create.headers().get("Content-Type").startsWith("multipart/form-data; boundary=")); + String body = transport.lastBody(); + assertTrue(body.contains("name=\"post\"\r\n\r\n{\"workspace_id\":\"w1\"")); + assertTrue(body.contains("\"text\":\"With a picture\"")); + assertTrue(body.contains("name=\"files\"; filename=\"chart.png\"\r\nContent-Type: image/png")); + assertEquals(create.bodyLength(), FakeTransport.bodyOf(create).length); + } + + @Test + void createWithALargeFileUploadsDirectlyThenPostsJson() { + FakeTransport transport = new FakeTransport() + .enqueue(200, LIMITS) + .enqueue(201, """ + {"data":{"uploadId":"u1","uploadUrl":"https://bucket.test/u1","method":"PUT", + "headers":{"Content-Type":"video/mp4"},"expiresAt":"2026-09-19T12:00:00Z"}}""") + .enqueue(200, "", Map.of()) + .enqueue(201, "{\"data\":{\"id\":\"m1\",\"type\":\"video\",\"name\":\"big.mp4\"," + + "\"url\":\"https://cdn.test/big.mp4\",\"size\":314572800}}") + .enqueue(201, "{\"id\":\"p1\",\"status\":\"draft\"}"); + FileInput big = FileInput.of("big.mp4", "video/mp4", new ByteArrayInputStream(new byte[0]), 314572800L); + + Post post = TestSupport.client(transport).posts().create( + CreatePostParams.of("w1").accounts("a1").content("Launch video"), List.of(big)); + + assertEquals("p1", post.id()); + assertEquals(5, transport.callCount()); + assertEquals("https://api.fopost.test/v1/media/presign", transport.requests.get(1).url()); + assertEquals("https://bucket.test/u1", transport.requests.get(2).url()); + assertEquals(314572800L, transport.requests.get(2).bodyLength()); + HttpRequestData create = transport.last(); + assertEquals("application/json", create.headers().get("Content-Type")); + String body = transport.lastBody(); + assertTrue(body.contains("\"content\":[{\"text\":\"Launch video\",\"media\":[{\"type\":\"video\"," + + "\"name\":\"big.mp4\",\"url\":\"https://cdn.test/big.mp4\",\"size\":314572800}]}]")); + } + + @Test + void createFallsBackToTheDefaultCeilingWhenLimitsFail() { + FakeTransport transport = new FakeTransport() + .enqueue(500, "{\"error\":\"server_error\",\"message\":\"nope\"}") + .enqueue(201, "{\"id\":\"p1\"}"); + + TestSupport.client(transport).posts().create( + CreatePostParams.of("w1").content("x"), + List.of(FileInput.of("a.png", "image/png", new byte[] {1}))); + + assertEquals(2, transport.callCount()); + assertTrue(transport.last().headers().get("Content-Type").startsWith("multipart/form-data")); + } + + @Test + void updateWithALargeFileAndNoContentAppendsToTheExistingFirstBlock() { + FakeTransport transport = new FakeTransport() + .enqueue(200, LIMITS) + .enqueue(200, """ + {"id":"p1","workspace_id":"w1","content":[ + {"id":"b1","text":"Already here","media":[{"type":"image","name":"old.png", + "url":"https://cdn.test/old.png","size":10}]}, + {"id":"b2","text":"Second block"}]}""") + .enqueue(201, """ + {"data":{"uploadId":"u1","uploadUrl":"https://bucket.test/u1","method":"PUT", + "headers":{},"expiresAt":"2026-09-19T12:00:00Z"}}""") + .enqueue(200, "", Map.of()) + .enqueue(201, "{\"data\":{\"id\":\"m1\",\"type\":\"video\",\"name\":\"big.mp4\"," + + "\"url\":\"https://cdn.test/big.mp4\",\"size\":200000000}}") + .enqueue(200, "{\"id\":\"p1\"}"); + FileInput big = FileInput.of("big.mp4", "video/mp4", new ByteArrayInputStream(new byte[0]), 200000000L); + + TestSupport.client(transport).posts().update("p1", UpdatePostParams.create().title("Renamed"), List.of(big)); + + assertEquals(6, transport.callCount()); + assertEquals("https://api.fopost.test/v1/media/limits", transport.requests.get(0).url()); + assertEquals("GET", transport.requests.get(1).method()); + assertEquals("https://api.fopost.test/v1/posts/p1", transport.requests.get(1).url()); + assertTrue(transport.requests.get(2).url().endsWith("/v1/media/presign")); + assertTrue(new String(transport.requests.get(2).body(), StandardCharsets.UTF_8).contains("\"workspaceId\":\"w1\"")); + HttpRequestData update = transport.last(); + assertEquals("PUT", update.method()); + String body = transport.lastBody(); + assertTrue(body.contains("\"title\":\"Renamed\"")); + assertTrue(body.contains("\"content\":[{\"text\":\"Already here\",\"media\":[" + + "{\"type\":\"image\",\"name\":\"old.png\",\"url\":\"https://cdn.test/old.png\",\"size\":10}," + + "{\"type\":\"video\",\"name\":\"big.mp4\",\"url\":\"https://cdn.test/big.mp4\",\"size\":200000000}]}," + + "{\"text\":\"Second block\"}]")); + } + + @Test + void fileTooLargeSurfacesAs413WithTheCeilingOnTheBody() { + FakeTransport transport = new FakeTransport().enqueue(413, """ + {"error":"file_too_large","message":"This file is larger than your plan allows", + "max_file_bytes":104857600,"file_bytes":314572800,"upgrade_url":"https://app.fopost.test/billing"}"""); + var client = TestSupport.client(transport); + + FoPostException error = assertThrows( + FoPostException.class, + () -> client.media().upload("w1", "big.mp4", new byte[] {1}, "video/mp4")); + + assertEquals(413, error.status()); + assertEquals("file_too_large", error.code()); + assertEquals("This file is larger than your plan allows", error.getMessage()); + JsonNode body = (JsonNode) error.body(); + assertEquals(104857600L, body.path("max_file_bytes").asLong()); + assertEquals(314572800L, body.path("file_bytes").asLong()); + assertEquals("https://app.fopost.test/billing", body.path("upgrade_url").asText()); + } + + private static List bytesOf(byte[] bytes) { + Integer[] boxed = new Integer[bytes.length]; + for (int i = 0; i < bytes.length; i++) { + boxed[i] = (int) bytes[i]; + } + return List.of(boxed); + } +}