Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 32 additions & 15 deletions src/main/java/com/fopost/sdk/internal/ApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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": ...}}
Expand Down Expand Up @@ -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<String, Object> query) {
byte[] encoded = null;
String contentType = null;
Map<String, String> 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<String, Object> query) {
private Map<String, String> baseHeaders() {
Map<String, String> 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) {
Expand Down Expand Up @@ -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<String, String> headers, byte[] body) {
Map<String, String> 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<String, String> headers, Supplier<InputStream> 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);
Expand Down Expand Up @@ -312,6 +325,10 @@ static Duration retryAfter(HttpResponseData response) {
}
}

private static Map<String, String> copy(Map<String, String> headers) {
return headers == null ? new LinkedHashMap<>() : new LinkedHashMap<>(headers);
}

private static Duration min(Duration a, Duration b) {
return a.compareTo(b) <= 0 ? a : b;
}
Expand Down
26 changes: 24 additions & 2 deletions src/main/java/com/fopost/sdk/internal/HttpRequestData.java
Original file line number Diff line number Diff line change
@@ -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<String, String> headers, byte[] body) {}
/**
* One outbound HTTP call, as handed to a {@link Transport}.
*
* <p>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<String, String> headers,
byte[] body,
Supplier<InputStream> bodyStream,
long bodyLength) {

public HttpRequestData(String method, String url, Map<String, String> headers, byte[] body) {
this(method, url, headers, body, null, body == null ? -1 : body.length);
}

public boolean hasBody() {
return body != null || bodyStream != null;
}
}
18 changes: 13 additions & 5 deletions src/main/java/com/fopost/sdk/internal/JdkTransport.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<byte[]> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
Expand All @@ -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();
}
}
Loading
Loading