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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## Unreleased

### Added

- Optional `instanceId` on `BlnkClientOptions` for [Cloud Proxy API](https://docs.blnkfinance.com/cloud/reference/proxy-api)
routing. When set, every request appends `instance_id` as a query parameter
(after existing query strings such as `limit`/`offset`). Use the Cloud proxy
base URL `https://api.cloud.blnkfinance.com/proxy` and a Cloud API key or
OAuth token; Core paths and bodies are unchanged. Self-hosted clients leave
`instanceId` unset.

## 1.5.0 — Core 0.15.4

Aligns the Java SDK error catalogue with [Blnk Core 0.15.4](https://docs.blnkfinance.com/changelog/blnk-core).
Expand Down
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ System.out.println("message: " + newLedger.message()); // "Success"
System.out.println("ledger: " + newLedger.data()); // parsed JSON body
```

Cloud Proxy: same Core methods and bodies, with the Cloud proxy base URL and
`instanceId`. Cloud routes with `instance_id` ([Proxy API](https://docs.blnkfinance.com/cloud/reference/proxy-api)):

```java
Blnk blnk = Blnk.init("<cloud_api_key_or_oauth_token>",
BlnkClientOptions.builder()
.baseUrl("https://api.cloud.blnkfinance.com/proxy")
.instanceId("instance_073f7ffe-9dfd-42ce-aa50-d1dca1788adc")
.build());
```

Use the instance id from Cloud (`instance_...`), not `deployment_id`. Self-hosted
Core clients omit `instanceId`.

A follow-up flow — create a balance and move money into it:

```java
Expand Down Expand Up @@ -171,6 +185,10 @@ Pass your Blnk secret key as the first argument to `Blnk.init`. When set, every
carries it in the `X-Blnk-Key` header; pass an empty string for unsecured self-hosted
instances.

For Cloud Proxy, pass a Cloud API key or OAuth access token as that first argument, set
`baseUrl` to `https://api.cloud.blnkfinance.com/proxy`, and set `instanceId` to the
target instance. Core request paths and bodies are unchanged.

## Services

Services are created lazily and cached per client instance:
Expand All @@ -188,7 +206,8 @@ without fractional seconds (`2026-12-31T23:59:59Z`), the format Blnk Core expect

| Option | Default | Notes |
|---|---|---|
| `baseUrl` | required | `IllegalArgumentException` if missing; `/` appended if absent |
| `baseUrl` | required | `IllegalArgumentException` if missing; `/` appended if absent. Use `https://api.cloud.blnkfinance.com/proxy` for Cloud Proxy |
| `instanceId` | unset | Cloud instance id (`instance_...`). When set, every request gets `instance_id` as a query parameter. Required for Proxy; omit for self-hosted Core |
| `timeout` | `10000` ms | per attempt; a timeout produces a synthetic `408` response and is never retried |
| `retryCount` | `1` | TOTAL attempts including the first; retries apply to `GET` requests only |
| `retryDelayMs` | `2000` | linear backoff: `delay × attemptNumber` |
Expand Down
17 changes: 14 additions & 3 deletions src/main/java/com/blnkfinance/blnk/Blnk.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.blnkfinance.blnk.util.MultipartBody;
import com.blnkfinance.blnk.util.RequestRetry;
import com.blnkfinance.blnk.util.SafeLogMeta;
import com.blnkfinance.blnk.util.UriEncoding;
import com.fasterxml.jackson.databind.JsonNode;

import java.nio.charset.StandardCharsets;
Expand All @@ -49,7 +50,8 @@ public final class Blnk {
* The client's stored options: the logger is kept separately, baseUrl has a
* trailing slash appended, and the timeout/retry values are normalized.
*/
record StoredOptions(String baseUrl, double timeout, int retryCount, double retryDelayMs) {}
record StoredOptions(
String baseUrl, String instanceId, double timeout, int retryCount, double retryDelayMs) {}

private static final ExecutorService TRANSPORT_EXECUTOR =
Executors.newCachedThreadPool(runnable -> {
Expand Down Expand Up @@ -87,7 +89,14 @@ public Blnk(
options.timeout() != null ? options.timeout() : ClientDefaults.DEFAULT_TIMEOUT_MS;
int retryCount = RequestRetry.normalizeRetryCount(options.retryCount());
double retryDelayMs = RequestRetry.normalizeRetryDelayMs(options.retryDelayMs());
this.options = new StoredOptions(baseUrl, timeout, retryCount, retryDelayMs);
String instanceId = options.instanceId();
this.options =
new StoredOptions(
baseUrl,
instanceId == null || instanceId.isEmpty() ? null : instanceId,
timeout,
retryCount,
retryDelayMs);

// No logger configured → the plain console logger. Blnk.init injects a
// CustomLogger first, so CONSOLE only appears when constructing Blnk
Expand Down Expand Up @@ -158,7 +167,9 @@ ApiResponse<JsonNode> request(
}
headers.putAll(formDataHeaders);

String url = this.options.baseUrl() + endpoint;
String url =
UriEncoding.appendQueryParam(
this.options.baseUrl() + endpoint, "instance_id", this.options.instanceId());

for (int attempt = 1; attempt <= maxAttempts; attempt++) {
if (attempt > 1) {
Expand Down
26 changes: 24 additions & 2 deletions src/main/java/com/blnkfinance/blnk/BlnkClientOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,32 @@

/**
* Client configuration: a required {@code baseUrl} plus optional
* {@code timeout}, {@code retryCount}, {@code retryDelayMs}, and
* {@code logger}. Immutable — normalization (the trailing slash on baseUrl,
* {@code instanceId}, {@code timeout}, {@code retryCount}, {@code retryDelayMs},
* and {@code logger}. Immutable — normalization (the trailing slash on baseUrl,
* logger injection by {@code Blnk.init}, retry defaults) is never applied to
* this object; the normalized values live on the constructed {@link Blnk}.
*
* <p>{@code retryCount}/{@code retryDelayMs}/{@code timeout} are boxed doubles
* so out-of-range inputs (NaN, ±Infinity, fractional counts such as 2.9) stay
* representable and are normalized by a single well-defined policy.
*
* <p>{@code instanceId} is the Cloud instance id ({@code instance_...}) sent as
* the {@code instance_id} query parameter on every request. Required when
* {@code baseUrl} is the Cloud Proxy ({@code https://api.cloud.blnkfinance.com/proxy}).
* Leave it unset for self-hosted Core.
*/
public final class BlnkClientOptions {

private final String baseUrl;
private final String instanceId;
private final Double timeout;
private final Double retryCount;
private final Double retryDelayMs;
private final BlnkLogger logger;

private BlnkClientOptions(Builder builder) {
this.baseUrl = builder.baseUrl;
this.instanceId = builder.instanceId;
this.timeout = builder.timeout;
this.retryCount = builder.retryCount;
this.retryDelayMs = builder.retryDelayMs;
Expand All @@ -35,6 +42,10 @@ public String baseUrl() {
return baseUrl;
}

public String instanceId() {
return instanceId;
}

public Double timeout() {
return timeout;
}
Expand All @@ -55,6 +66,7 @@ public BlnkLogger logger() {
public BlnkClientOptions withLogger(BlnkLogger logger) {
Builder builder = new Builder();
builder.baseUrl = this.baseUrl;
builder.instanceId = this.instanceId;
builder.timeout = this.timeout;
builder.retryCount = this.retryCount;
builder.retryDelayMs = this.retryDelayMs;
Expand All @@ -64,6 +76,7 @@ public BlnkClientOptions withLogger(BlnkLogger logger) {

public static final class Builder {
private String baseUrl;
private String instanceId;
private Double timeout;
private Double retryCount;
private Double retryDelayMs;
Expand All @@ -74,6 +87,15 @@ public Builder baseUrl(String baseUrl) {
return this;
}

/**
* Cloud instance id for Proxy API routing ({@code instance_...}, not
* {@code deployment_id}). Sent as {@code instance_id} on every request.
*/
public Builder instanceId(String instanceId) {
this.instanceId = instanceId;
return this;
}

public Builder timeout(double timeout) {
this.timeout = timeout;
return this;
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/com/blnkfinance/blnk/util/UriEncoding.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,19 @@ public static String encodePathSegment(String value) {
}
return sb.toString();
}

/**
* Appends {@code name=value} to {@code url}. Uses {@code ?} when the URL has
* no query string yet, otherwise {@code &}. {@code value} is percent-encoded
* the same way as a path segment. A null or empty {@code value} leaves
* {@code url} unchanged so self-hosted Core calls are not given a Cloud
* routing param.
*/
public static String appendQueryParam(String url, String name, String value) {
if (url == null || name == null || name.isEmpty() || value == null || value.isEmpty()) {
return url;
}
char separator = url.indexOf('?') >= 0 ? '&' : '?';
return url + separator + name + '=' + encodePathSegment(value);
}
}
93 changes: 93 additions & 0 deletions src/test/java/com/blnkfinance/blnk/BaseBlnkClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,99 @@ void sendsApiKeyHeader() {
assertEquals(apiKey, headers.get("X-Blnk-Key"));
}

@Test
@DisplayName("Omits instance_id when instanceId is not configured")
void omitsInstanceIdQueryParam() {
CapturingTransport capturedTransport = CapturingTransport.of(TransportMocks.successTransport());
Blnk authedBlnk =
new Blnk(apiKey, options, mockServices, HttpClientUtils.FORMAT_RESPONSE, capturedTransport);

authedBlnk.request("health", obj(), "GET", null);

assertEquals("http://mock-api.com/health", capturedTransport.calls.get(0).url());
}

@Test
@DisplayName("Appends instance_id query param for Cloud proxy requests")
void appendsInstanceIdQueryParam() {
CapturingTransport capturedTransport = CapturingTransport.of(TransportMocks.successTransport());
BlnkClientOptions proxyOptions =
BlnkClientOptions.builder()
.baseUrl("https://api.cloud.blnkfinance.com/proxy")
.instanceId("instance_073f7ffe-9dfd-42ce-aa50-d1dca1788adc")
.timeout(5000)
.logger(options.logger())
.build();
Blnk proxyBlnk =
new Blnk(
apiKey, proxyOptions, mockServices, HttpClientUtils.FORMAT_RESPONSE, capturedTransport);

proxyBlnk.request("ledgers", obj(), "POST", null);

assertEquals(
"https://api.cloud.blnkfinance.com/proxy/ledgers"
+ "?instance_id=instance_073f7ffe-9dfd-42ce-aa50-d1dca1788adc",
capturedTransport.calls.get(0).url());
}

@Test
@DisplayName("Appends instance_id after existing query parameters")
void appendsInstanceIdAfterExistingQuery() {
CapturingTransport capturedTransport = CapturingTransport.of(TransportMocks.successTransport());
BlnkClientOptions proxyOptions =
BlnkClientOptions.builder()
.baseUrl("https://api.cloud.blnkfinance.com/proxy")
.instanceId("instance_073f7ffe-9dfd-42ce-aa50-d1dca1788adc")
.timeout(5000)
.logger(options.logger())
.build();
Blnk proxyBlnk =
new Blnk(
apiKey, proxyOptions, mockServices, HttpClientUtils.FORMAT_RESPONSE, capturedTransport);

proxyBlnk.request("ledgers?limit=10&offset=0", null, "GET", null);

assertEquals(
"https://api.cloud.blnkfinance.com/proxy/ledgers?limit=10&offset=0"
+ "&instance_id=instance_073f7ffe-9dfd-42ce-aa50-d1dca1788adc",
capturedTransport.calls.get(0).url());
}

@Test
@DisplayName("Percent-encodes instance_id query values")
void encodesInstanceIdQueryParam() {
CapturingTransport capturedTransport = CapturingTransport.of(TransportMocks.successTransport());
BlnkClientOptions proxyOptions =
BlnkClientOptions.builder()
.baseUrl("https://api.cloud.blnkfinance.com/proxy")
.instanceId("instance id/with?special")
.timeout(5000)
.logger(options.logger())
.build();
Blnk proxyBlnk =
new Blnk(
apiKey, proxyOptions, mockServices, HttpClientUtils.FORMAT_RESPONSE, capturedTransport);

proxyBlnk.request("health", null, "GET", null);

assertEquals(
"https://api.cloud.blnkfinance.com/proxy/health"
+ "?instance_id=instance%20id%2Fwith%3Fspecial",
capturedTransport.calls.get(0).url());
}

@Test
@DisplayName("withLogger keeps instanceId")
void withLoggerKeepsInstanceId() {
BlnkClientOptions original =
BlnkClientOptions.builder()
.baseUrl("https://api.cloud.blnkfinance.com/proxy")
.instanceId("instance_abc")
.build();
BlnkClientOptions copied = original.withLogger(options.logger());
assertEquals("instance_abc", copied.instanceId());
}

@Test
@DisplayName("Does not expose public getApiKey getter")
void noGetApiKey() {
Expand Down
Loading