From 24d2697f4d16b8388a34e778b13ad79837bc24e4 Mon Sep 17 00:00:00 2001 From: xxvw Date: Mon, 27 Jul 2026 11:50:16 +0900 Subject: [PATCH 1/2] feat: add environment-backed client builders --- .github/workflows/ci.yml | 38 +++++ CHANGELOG.md | 21 +++ README.md | 57 +++++++- docs/guides/async.md | 9 +- docs/guides/custom-transport.md | 7 +- docs/guides/production-usage.md | 20 ++- docs/guides/quick-start.md | 39 ++++- .../01-public-api.md | 23 ++- .../03-http-and-d1-rest-api.md | 10 +- .../07-testing-requirements.md | 20 +++ .../09-implementation-task-plan.md | 10 +- .../10-acceptance-checklist.md | 5 + .../11-usage-examples.md | 19 +++ docs/requirements/phase-02-public-api.md | 22 +-- .../phase-03-http-retry-validation.md | 4 + examples/quickstart/pom.xml | 3 +- .../xxvw/cloudflare/d1/D1AsyncClient.java | 26 +++- .../cloudflare/d1/D1AsyncClientBuilder.java | 15 +- .../github/xxvw/cloudflare/d1/D1Client.java | 37 +++-- .../xxvw/cloudflare/d1/D1ClientBuilder.java | 11 ++ .../cloudflare/d1/D1TransportRequest.java | 6 +- .../HttpURLConnectionD1Transport.java | 12 +- .../d1/D1EnvironmentBuilderTest.java | 137 ++++++++++++++++++ .../HttpURLConnectionD1TransportTest.java | 15 ++ 24 files changed, 511 insertions(+), 55 deletions(-) create mode 100644 src/test/java/io/github/xxvw/cloudflare/d1/D1EnvironmentBuilderTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b86f221..49491a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,3 +63,41 @@ jobs: - name: Verify release artifacts run: mvn -B -Prelease -Dgpg.skip=true verify + + examples: + name: Compile and run examples + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Java + uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + with: + distribution: temurin + java-version: "17" + cache: maven + + - name: Install current library + run: mvn -B -DskipTests install + + - name: Read current project version + id: project-version + run: echo "version=$(mvn -q -DforceStdout help:evaluate -Dexpression=project.version)" >> "$GITHUB_OUTPUT" + + - name: Compile examples + run: >- + mvn -B -f examples/quickstart/pom.xml + -Dcloudflare-d1-java.version=${{ steps.project-version.outputs.version }} + compile + + - name: Run credential-free custom transport example + run: >- + mvn -B -f examples/quickstart/pom.xml + -Dcloudflare-d1-java.version=${{ steps.project-version.outputs.version }} + exec:java + -Dexec.mainClass=example.CustomTransportExample diff --git a/CHANGELOG.md b/CHANGELOG.md index 2661563..9311b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ This project follows Semantic Versioning. ## [Unreleased] +### Added + +- Added `D1Client.builderFromEnv()` and `D1AsyncClient.builderFromEnv()` so standard environment + credentials can be combined with timeout, retry, transport, mapping, and executor configuration. + +### Changed + +- Made the quickstart dependency version overridable and added Java 17 CI coverage that compiles + all examples against the current project artifact and runs the credential-free custom transport + example. + +### Fixed + +- Preserved positive sub-millisecond default transport timeouts by rounding them up to one + millisecond, while keeping zero unlimited and safely capping oversized durations. +- Added the missing Maven `compile` phase to parameterized quickstart commands. + +### Documentation + +- Added environment-backed builder, Gradle Kotlin DSL, PowerShell, and timeout behavior guidance. + ## [0.2.0] - 2026-07-11 See the [migration guide](docs/guides/migration-0.2.md) for upgrade steps from 0.1.x. diff --git a/README.md b/README.md index 1e8d2ac..fc69282 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,18 @@ Use the Workers D1 binding for code running inside Cloudflare Workers. Use a Wor ``` -Gradle: +Gradle Groovy DSL: ```groovy implementation "io.github.xxvw:cloudflare-d1-java:0.2.0" ``` +Gradle Kotlin DSL: + +```kotlin +implementation("io.github.xxvw:cloudflare-d1-java:0.2.0") +``` + Requirements: - Java 8 or newer. @@ -75,6 +81,20 @@ set -a set +a ``` +PowerShell: + +```powershell +Copy-Item .env.example .env +notepad .env + +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*)=(.*)$') { + [Environment]::SetEnvironmentVariable( + $matches[1].Trim(), $matches[2].Trim(), "Process") + } +} +``` + The `.env` file is ignored by Git. Never commit real Cloudflare account IDs, database IDs, or API tokens. @@ -90,6 +110,22 @@ try (D1Client d1 = D1Client.fromEnv()) { } ``` +Use `builderFromEnv()` when you want the same environment variable loading with additional +configuration: + +```java +try (D1Client d1 = D1Client.builderFromEnv() + .connectTimeout(Duration.ofSeconds(10)) + .requestTimeout(Duration.ofSeconds(45)) + .retryPolicy(D1RetryPolicy.none()) + .build()) { + D1Result result = d1.query("SELECT 1 AS value"); +} +``` + +`fromEnv()` is equivalent to `builderFromEnv().build()`. A zero connect or request timeout means no +timeout when using the default transport. + For a one-minute local check, run the bundled example: ```bash @@ -131,7 +167,7 @@ Rows: 1 Pass a SQL statement as `exec.args` to try another read query: ```bash -mvn -f examples/quickstart/pom.xml exec:java -Dexec.args="SELECT 42 AS answer" +mvn -f examples/quickstart/pom.xml compile exec:java -Dexec.args="SELECT 42 AS answer" ``` Run typed mapping and opt-in write examples: @@ -330,10 +366,7 @@ The default transport uses the Java standard library and does not add runtime de ```java import java.util.Collections; -D1Client client = D1Client.builder() - .accountId(System.getenv("CLOUDFLARE_ACCOUNT_ID")) - .databaseId(System.getenv("D1_DATABASE_ID")) - .apiToken(System.getenv("CLOUDFLARE_API_TOKEN")) +D1Client client = D1Client.builderFromEnv() .transport(request -> { return new D1TransportResponse(200, Collections.emptyMap(), "{\"success\":true,\"result\":[]}"); }) @@ -354,6 +387,18 @@ try (D1AsyncClient d1 = D1AsyncClient.fromEnv()) { } ``` +To customize timeouts, retry behavior, mapping, transport, or the executor while retaining the +standard environment variables, start from `D1AsyncClient.builderFromEnv()`: + +```java +try (D1AsyncClient d1 = D1AsyncClient.builderFromEnv() + .requestTimeout(Duration.ofSeconds(45)) + .executor(executor) + .build()) { + D1Result result = d1.queryAsync("SELECT 1 AS value").join(); +} +``` + Async operations run on a Java `Executor` and complete futures exceptionally with the same public exception types as synchronous operations. Run the read-only async examples: diff --git a/docs/guides/async.md b/docs/guides/async.md index 3fe3efd..2d4acfb 100644 --- a/docs/guides/async.md +++ b/docs/guides/async.md @@ -21,10 +21,7 @@ By default, the client creates its own executor backed by named daemon threads ( ```java ExecutorService executor = Executors.newFixedThreadPool(4); -try (D1AsyncClient d1 = D1AsyncClient.builder() - .accountId(System.getenv("CLOUDFLARE_ACCOUNT_ID")) - .databaseId(System.getenv("D1_DATABASE_ID")) - .apiToken(System.getenv("CLOUDFLARE_API_TOKEN")) +try (D1AsyncClient d1 = D1AsyncClient.builderFromEnv() .executor(executor) .build()) { CompletableFuture future = d1.queryAsync("SELECT 1 AS value"); @@ -35,6 +32,10 @@ try (D1AsyncClient d1 = D1AsyncClient.builder() } ``` +`D1AsyncClient.fromEnv()` is equivalent to `D1AsyncClient.builderFromEnv().build()`. The +environment-backed builder also accepts timeout, retry policy, custom transport, and typed mapping +configuration before `build()`. + A caller-supplied executor is owned by your application. Closing `D1AsyncClient` never shuts it down; only the client-created default executor is shut down on close. ## Run Parallel Queries diff --git a/docs/guides/custom-transport.md b/docs/guides/custom-transport.md index d5d56d4..e243609 100644 --- a/docs/guides/custom-transport.md +++ b/docs/guides/custom-transport.md @@ -9,10 +9,7 @@ D1Transport transport = request -> { return new D1TransportResponse(200, Collections.emptyMap(), "{\"success\":true,\"result\":[]}"); }; -D1Client client = D1Client.builder() - .accountId(System.getenv("CLOUDFLARE_ACCOUNT_ID")) - .databaseId(System.getenv("D1_DATABASE_ID")) - .apiToken(System.getenv("CLOUDFLARE_API_TOKEN")) +D1Client client = D1Client.builderFromEnv() .transport(transport) .build(); ``` @@ -21,7 +18,7 @@ D1Client client = D1Client.builder() - Return the HTTP status code, response headers, and response body exactly enough for the client to parse D1 responses. - Preserve the `Authorization`, `Content-Type`, `Accept`, and `User-Agent` headers from the request. -- Respect `request.timeout()` where your HTTP stack supports it. +- Respect `request.timeout()` where your HTTP stack supports it. `Duration.ZERO` means no timeout. - Do not log API tokens or authorization headers. The custom transport does not change JSON parsing, retry behavior, or exception mapping. diff --git a/docs/guides/production-usage.md b/docs/guides/production-usage.md index c7d5fe3..fec8048 100644 --- a/docs/guides/production-usage.md +++ b/docs/guides/production-usage.md @@ -12,7 +12,7 @@ For latency-sensitive application traffic, consider putting a Cloudflare Worker - Do not log `Authorization` headers. - Do not include account IDs, database IDs, tokens, customer data, or production SQL data in public issues. -The built-in `fromEnv()` helpers read: +The built-in `fromEnv()` and `builderFromEnv()` helpers read: ```text CLOUDFLARE_ACCOUNT_ID @@ -20,6 +20,18 @@ D1_DATABASE_ID CLOUDFLARE_API_TOKEN ``` +Use `fromEnv()` for defaults, or start from `builderFromEnv()` to retain these credential conventions +while configuring timeouts, retry behavior, a custom transport, typed mapping, or an async executor: + +```java +try (D1Client d1 = D1Client.builderFromEnv() + .requestTimeout(Duration.ofSeconds(45)) + .retryPolicy(D1RetryPolicy.none()) + .build()) { + D1Result result = d1.query("SELECT 1 AS value"); +} +``` + ## Retry Policy Read queries retry transient failures by default. Write operations and batches do not retry by default because a repeated write may not be idempotent. @@ -37,6 +49,12 @@ See [Retry Policy](retry-policy.md) for configuration examples. Configure timeouts based on the job type. Short interactive jobs usually need lower timeouts than background maintenance jobs. +With the default transport, `connectTimeout` limits connection establishment and `requestTimeout` +is used as the `HttpURLConnection` read timeout. `Duration.ZERO` disables the corresponding +timeout. Positive sub-millisecond values are rounded up to one millisecond, and values beyond the +`HttpURLConnection` integer-millisecond range are capped. Custom transports receive the configured +request duration through `D1TransportRequest.timeout()` and are responsible for enforcing it. + Handle these exception categories separately: - `D1AuthenticationException`: token is missing, malformed, expired, or invalid. diff --git a/docs/guides/quick-start.md b/docs/guides/quick-start.md index 99c4aca..0482c2c 100644 --- a/docs/guides/quick-start.md +++ b/docs/guides/quick-start.md @@ -14,12 +14,18 @@ Java 8 or newer is required. -Gradle: +Gradle Groovy DSL: ```groovy implementation "io.github.xxvw:cloudflare-d1-java:0.2.0" ``` +Gradle Kotlin DSL: + +```kotlin +implementation("io.github.xxvw:cloudflare-d1-java:0.2.0") +``` + ## Configure Credentials Set the required environment variables. For local manual testing from this repository, copy the @@ -34,6 +40,20 @@ set -a set +a ``` +PowerShell: + +```powershell +Copy-Item .env.example .env +notepad .env + +Get-Content .env | ForEach-Object { + if ($_ -match '^\s*([^#][^=]*)=(.*)$') { + [Environment]::SetEnvironmentVariable( + $matches[1].Trim(), $matches[2].Trim(), "Process") + } +} +``` + Create the API token in the Cloudflare dashboard. For write operations, use an account-scoped D1 token with Edit permission. Store tokens outside source control and never paste production values into issues or logs. ## Run a Query @@ -56,6 +76,21 @@ public final class Example { } ``` +When you need optional settings, initialize a builder from the same variables and customize it +before building: + +```java +try (D1Client d1 = D1Client.builderFromEnv() + .connectTimeout(Duration.ofSeconds(10)) + .requestTimeout(Duration.ofSeconds(45)) + .retryPolicy(D1RetryPolicy.none()) + .build()) { + D1Result result = d1.query("SELECT 1 AS value"); +} +``` + +`D1Client.fromEnv()` is equivalent to `D1Client.builderFromEnv().build()`. + ## Run the Repository Example The repository includes a standalone Maven example that runs the same flow from environment variables. @@ -79,7 +114,7 @@ Rows: 1 To run a different read query: ```bash -mvn -f examples/quickstart/pom.xml exec:java -Dexec.args="SELECT 42 AS answer" +mvn -f examples/quickstart/pom.xml compile exec:java -Dexec.args="SELECT 42 AS answer" ``` To run the read-only async examples: diff --git a/docs/implementation-requirements/01-public-api.md b/docs/implementation-requirements/01-public-api.md index 51a9698..b45e7a3 100644 --- a/docs/implementation-requirements/01-public-api.md +++ b/docs/implementation-requirements/01-public-api.md @@ -22,6 +22,10 @@ Environment-based creation: ```java D1Client client = D1Client.fromEnv(); +D1ClientBuilder builder = D1Client.builderFromEnv(); + +D1AsyncClient asyncClient = D1AsyncClient.fromEnv(); +D1AsyncClientBuilder asyncBuilder = D1AsyncClient.builderFromEnv(); ``` Environment variables: @@ -32,7 +36,12 @@ D1_DATABASE_ID CLOUDFLARE_API_TOKEN ``` -If an environment variable is missing or blank, `fromEnv()` must throw `IllegalStateException`. +If an environment variable is missing or blank, `fromEnv()` and `builderFromEnv()` must throw +`IllegalStateException`. + +`fromEnv()` must be equivalent to `builderFromEnv().build()`. The returned builders must accept all +normal optional configuration, including timeout, retry policy, transport, mapping `ObjectMapper`, +and async executor settings. The repository must include a tracked `.env.example` file with these variable names so contributors can create a local ignored `.env` file for manual testing. `.env` files must remain ignored and must @@ -82,6 +91,15 @@ retryPolicy = D1RetryPolicy.defaultPolicy() userAgent = cloudflare-d1-java/{version} ``` +Timeout behavior for the default transport: + +- `connectTimeout` controls connection establishment. +- `requestTimeout` is applied as the `HttpURLConnection` read timeout. +- `Duration.ZERO` disables the corresponding timeout. +- Positive values below one millisecond are rounded up to one millisecond. +- Values at or above `Integer.MAX_VALUE` milliseconds are capped without overflow. +- Custom transports receive the configured request duration unchanged. + ## D1Query ```java @@ -175,4 +193,7 @@ Rules: The deprecated preview async methods on `D1Client` (`queryAsync`, `executeAsync`, `batchAsync`) were removed in v0.2.0. `D1AsyncClient` is the supported asynchronous entry point. +`D1AsyncClient.builderFromEnv()` must return a fully configurable `D1AsyncClientBuilder`. A +caller-supplied executor remains caller-owned. + Async failures must complete the future exceptionally. diff --git a/docs/implementation-requirements/03-http-and-d1-rest-api.md b/docs/implementation-requirements/03-http-and-d1-rest-api.md index 8266569..73755ea 100644 --- a/docs/implementation-requirements/03-http-and-d1-rest-api.md +++ b/docs/implementation-requirements/03-http-and-d1-rest-api.md @@ -34,8 +34,14 @@ java.net.HttpURLConnection Rules: - A custom `D1Transport` can be provided by `D1ClientBuilder`. -- Request timeout is applied per request. -- Connect timeout is used when the default client is created. +- Request timeout is included in every `D1TransportRequest`; the default transport applies it as + the `HttpURLConnection` read timeout. +- Connect timeout controls connection establishment in the default transport. +- `Duration.ZERO` maps to zero milliseconds and disables the corresponding default transport + timeout. +- Positive sub-millisecond timeouts round up to one millisecond. +- Timeouts at or above `Integer.MAX_VALUE` milliseconds cap at `Integer.MAX_VALUE` without + overflowing `Duration.toMillis()`. - No third-party HTTP client dependency is allowed in v0.1.0. ## Headers diff --git a/docs/implementation-requirements/07-testing-requirements.md b/docs/implementation-requirements/07-testing-requirements.md index 20d5ba7..a9bf375 100644 --- a/docs/implementation-requirements/07-testing-requirements.md +++ b/docs/implementation-requirements/07-testing-requirements.md @@ -96,6 +96,26 @@ Required cases: - Reject blank base URL - Reject invalid URI string +## Environment Builder Tests + +Required cases: + +- Missing and blank standard environment values throw `IllegalStateException` +- `D1Client.fromEnv()` delegates to an environment-backed builder +- Environment-backed sync builders accept timeout, retry, custom transport, and mapping settings +- Environment-backed async builders accept a caller-supplied executor +- Tests use only fake account IDs, database IDs, and tokens + +## Default Transport Timeout Tests + +Required cases: + +- Zero maps to unlimited (`0`) +- Positive sub-millisecond values map to one millisecond +- Normal millisecond values are preserved +- Values at or above `Integer.MAX_VALUE` milliseconds are capped +- Huge `Duration` values do not overflow during conversion + ## D1Result and D1Meta Tests Required cases: diff --git a/docs/implementation-requirements/09-implementation-task-plan.md b/docs/implementation-requirements/09-implementation-task-plan.md index 98d522d..c5e8c7c 100644 --- a/docs/implementation-requirements/09-implementation-task-plan.md +++ b/docs/implementation-requirements/09-implementation-task-plan.md @@ -85,14 +85,16 @@ Acceptance criteria: - Request body shape is correct. - Transport and timeout errors are mapped. -## Task 11: Implement D1ClientBuilder and fromEnv +## Task 11: Implement client builders and environment-backed builders Acceptance criteria: - Builder defaults work. - Required values are validated. -- `fromEnv()` loads expected environment variables. +- `fromEnv()` and `builderFromEnv()` load expected environment variables. +- Environment-backed builders remain configurable before `build()`. - Missing env values throw `IllegalStateException`. +- Sync and async environment-backed builders are covered by tests. ## Task 12: Implement Query and Execute @@ -151,4 +153,6 @@ Acceptance criteria: - README examples are aligned with implemented API. - Limitations are documented. - Security notes are documented. -- Async preview status is documented. +- Sync and async environment-backed configuration is documented. +- Maven, Gradle Groovy DSL, Gradle Kotlin DSL, POSIX shell, and PowerShell onboarding is documented. +- Standalone examples compile against the current root project version in CI. diff --git a/docs/implementation-requirements/10-acceptance-checklist.md b/docs/implementation-requirements/10-acceptance-checklist.md index 9c23156..fd977da 100644 --- a/docs/implementation-requirements/10-acceptance-checklist.md +++ b/docs/implementation-requirements/10-acceptance-checklist.md @@ -4,6 +4,8 @@ - [ ] `D1Client.builder()` works. - [ ] `D1Client.fromEnv()` works. +- [ ] `D1Client.builderFromEnv()` remains configurable before `build()`. +- [ ] `D1AsyncClient.builderFromEnv()` accepts custom executor configuration. - [ ] `.env.example` documents the variables required by `D1Client.fromEnv()`. - [ ] `query()` works with no params. - [ ] `query()` works with varargs params. @@ -25,6 +27,7 @@ - [ ] Request body for batch is correct. - [ ] `params: []` is sent when no params exist. - [ ] `rawBody()` is preserved. +- [ ] Zero, sub-millisecond, and oversized default transport timeouts are converted safely. ## Validation @@ -84,3 +87,5 @@ - [ ] Javadocs build. - [ ] Sources jar builds. - [ ] Release profile signs artifacts. +- [ ] Standalone examples compile against the current project version. +- [ ] The credential-free custom transport example runs in CI. diff --git a/docs/implementation-requirements/11-usage-examples.md b/docs/implementation-requirements/11-usage-examples.md index 8e7e8e1..4930b68 100644 --- a/docs/implementation-requirements/11-usage-examples.md +++ b/docs/implementation-requirements/11-usage-examples.md @@ -18,6 +18,25 @@ D1Client d1 = D1Client.builder() D1Client d1 = D1Client.fromEnv(); ``` +## Customize a Client from Environment + +```java +D1Client d1 = D1Client.builderFromEnv() + .connectTimeout(Duration.ofSeconds(10)) + .requestTimeout(Duration.ofSeconds(45)) + .retryPolicy(D1RetryPolicy.none()) + .build(); +``` + +## Customize an Async Client from Environment + +```java +D1AsyncClient d1 = D1AsyncClient.builderFromEnv() + .requestTimeout(Duration.ofSeconds(45)) + .executor(executor) + .build(); +``` + ## Query ```java diff --git a/docs/requirements/phase-02-public-api.md b/docs/requirements/phase-02-public-api.md index 1b1bb81..26e3640 100644 --- a/docs/requirements/phase-02-public-api.md +++ b/docs/requirements/phase-02-public-api.md @@ -20,6 +20,10 @@ Environment-based creation: ```java D1Client client = D1Client.fromEnv(); +D1ClientBuilder builder = D1Client.builderFromEnv(); + +D1AsyncClient asyncClient = D1AsyncClient.fromEnv(); +D1AsyncClientBuilder asyncBuilder = D1AsyncClient.builderFromEnv(); ``` Required environment variables: @@ -30,6 +34,10 @@ D1_DATABASE_ID CLOUDFLARE_API_TOKEN ``` +Missing or blank environment variables must throw `IllegalStateException`. `fromEnv()` is +equivalent to `builderFromEnv().build()`, while the builder entry points allow optional timeout, +retry, transport, typed mapping, and async executor configuration. + ## Query API ```java @@ -84,15 +92,11 @@ public List batch(List queries); public List batch(D1Query... queries); ``` -## Preview Async API +## Async API ```java -@Deprecated(since = "0.1.0", forRemoval = false) -public CompletableFuture queryAsync(String sql, Object... params); - -@Deprecated(since = "0.1.0", forRemoval = false) -public CompletableFuture executeAsync(String sql, Object... params); - -@Deprecated(since = "0.1.0", forRemoval = false) -public CompletableFuture> batchAsync(List queries); +public final class D1AsyncClient implements AutoCloseable ``` + +`D1AsyncClient` provides future-returning equivalents of the synchronous query, execute, batch, +raw, and typed mapping APIs. Caller-supplied executors remain caller-owned. diff --git a/docs/requirements/phase-03-http-retry-validation.md b/docs/requirements/phase-03-http-retry-validation.md index 02283ac..bc8bc9f 100644 --- a/docs/requirements/phase-03-http-retry-validation.md +++ b/docs/requirements/phase-03-http-retry-validation.md @@ -12,6 +12,10 @@ Default base URL: https://api.cloudflare.com/client/v4 ``` +The default transport uses a five-second connect timeout and a 30-second read timeout. Zero +disables either timeout, positive sub-millisecond durations round up to one millisecond, and values +outside the `HttpURLConnection` integer-millisecond range are capped without overflow. + ## Headers Required headers: diff --git a/examples/quickstart/pom.xml b/examples/quickstart/pom.xml index 6d8c398..37c6254 100644 --- a/examples/quickstart/pom.xml +++ b/examples/quickstart/pom.xml @@ -12,6 +12,7 @@ UTF-8 8 + 0.2.0 example.QuickStart @@ -19,7 +20,7 @@ io.github.xxvw cloudflare-d1-java - 0.2.0 + ${cloudflare-d1-java.version} diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClient.java b/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClient.java index a49305b..1bcfab0 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClient.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClient.java @@ -55,22 +55,40 @@ public static D1AsyncClientBuilder builder() { return new D1AsyncClientBuilder(); } + /** + * Creates an async builder initialized from the standard environment variables. + * + *

The required variables are {@code CLOUDFLARE_ACCOUNT_ID}, {@code D1_DATABASE_ID}, and + * {@code CLOUDFLARE_API_TOKEN}. Optional settings such as timeouts, retries, transports, typed + * row mapping, and the executor can be customized on the returned builder before calling {@link + * D1AsyncClientBuilder#build()}. + * + * @return an async client builder initialized from environment variables + * @throws IllegalStateException if any required variable is missing or blank + */ + public static D1AsyncClientBuilder builderFromEnv() { + return builderFromEnv(System::getenv); + } + + static D1AsyncClientBuilder builderFromEnv(Function env) { + return new D1AsyncClientBuilder(D1Client.builderFromEnv(env)); + } + /** * Creates an async client from the standard environment variables. * *

The required variables are {@code CLOUDFLARE_ACCOUNT_ID}, {@code D1_DATABASE_ID}, and - * {@code CLOUDFLARE_API_TOKEN}. + * {@code CLOUDFLARE_API_TOKEN}. This is equivalent to {@code builderFromEnv().build()}. * * @return an async client configured from environment variables * @throws IllegalStateException if any required variable is missing or blank */ public static D1AsyncClient fromEnv() { - return fromEnv(System::getenv); + return builderFromEnv().build(); } static D1AsyncClient fromEnv(Function env) { - ExecutorService owned = D1AsyncClientBuilder.newOwnedExecutor(); - return new D1AsyncClient(D1Client.fromEnv(env), owned, owned); + return builderFromEnv(env).build(); } /** diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClientBuilder.java b/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClientBuilder.java index e22ab6c..1a8a43b 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClientBuilder.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/D1AsyncClientBuilder.java @@ -19,10 +19,16 @@ * daemon threads and shuts it down when the client closes. */ public final class D1AsyncClientBuilder { - private final D1ClientBuilder clientBuilder = D1Client.builder(); + private final D1ClientBuilder clientBuilder; private Executor executor; - D1AsyncClientBuilder() {} + D1AsyncClientBuilder() { + this(D1Client.builder()); + } + + D1AsyncClientBuilder(D1ClientBuilder clientBuilder) { + this.clientBuilder = Objects.requireNonNull(clientBuilder, "clientBuilder must not be null"); + } static ExecutorService newOwnedExecutor() { return Executors.newCachedThreadPool(D1Threads.daemonFactory("cloudflare-d1-async-")); @@ -97,6 +103,8 @@ public D1AsyncClientBuilder userAgent(String userAgent) { /** * Sets the connection timeout for the default HTTP client. * + *

See {@link D1ClientBuilder#connectTimeout(Duration)} for timeout conversion semantics. + * * @param connectTimeout non-negative timeout * @return this builder */ @@ -108,6 +116,9 @@ public D1AsyncClientBuilder connectTimeout(Duration connectTimeout) { /** * Sets the per-request timeout. * + *

See {@link D1ClientBuilder#requestTimeout(Duration)} for default and custom transport + * semantics. + * * @param requestTimeout non-negative timeout * @return this builder */ diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/D1Client.java b/src/main/java/io/github/xxvw/cloudflare/d1/D1Client.java index 13792c0..598a31f 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/D1Client.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/D1Client.java @@ -22,8 +22,8 @@ /** * Synchronous client for the Cloudflare D1 REST API. * - *

Create instances with {@link #builder()} or {@link #fromEnv()}. Instances are closeable to - * prevent accidental reuse after application shutdown. + *

Create instances with {@link #builder()}, {@link #builderFromEnv()}, or {@link #fromEnv()}. + * Instances are closeable to prevent accidental reuse after application shutdown. * *

Query operations ({@code query}, {@code queryFirst}, {@code raw}) are retried by default and * must be used for reads only: a retried statement can reach the server more than once @@ -82,25 +82,44 @@ public static D1ClientBuilder builder() { return new D1ClientBuilder(); } + /** + * Creates a builder initialized from the standard environment variables. + * + *

The required variables are {@code CLOUDFLARE_ACCOUNT_ID}, {@code D1_DATABASE_ID}, and + * {@code CLOUDFLARE_API_TOKEN}. Optional settings such as timeouts, retries, transports, and + * typed row mapping can be customized on the returned builder before calling {@link + * D1ClientBuilder#build()}. + * + * @return a client builder initialized from environment variables + * @throws IllegalStateException if any required variable is missing or blank + */ + public static D1ClientBuilder builderFromEnv() { + return builderFromEnv(System::getenv); + } + + static D1ClientBuilder builderFromEnv(Function env) { + Objects.requireNonNull(env, "env must not be null"); + return builder() + .accountId(requireEnv(env, ENV_ACCOUNT_ID)) + .databaseId(requireEnv(env, ENV_DATABASE_ID)) + .apiToken(requireEnv(env, ENV_API_TOKEN)); + } + /** * Creates a client from the standard environment variables. * *

The required variables are {@code CLOUDFLARE_ACCOUNT_ID}, {@code D1_DATABASE_ID}, and - * {@code CLOUDFLARE_API_TOKEN}. + * {@code CLOUDFLARE_API_TOKEN}. This is equivalent to {@code builderFromEnv().build()}. * * @return a client configured from environment variables * @throws IllegalStateException if any required variable is missing or blank */ public static D1Client fromEnv() { - return fromEnv(System::getenv); + return builderFromEnv().build(); } static D1Client fromEnv(Function env) { - return builder() - .accountId(requireEnv(env, ENV_ACCOUNT_ID)) - .databaseId(requireEnv(env, ENV_DATABASE_ID)) - .apiToken(requireEnv(env, ENV_API_TOKEN)) - .build(); + return builderFromEnv(env).build(); } /** diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/D1ClientBuilder.java b/src/main/java/io/github/xxvw/cloudflare/d1/D1ClientBuilder.java index 2091ef0..01f23a1 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/D1ClientBuilder.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/D1ClientBuilder.java @@ -105,6 +105,11 @@ public D1ClientBuilder userAgent(String userAgent) { /** * Sets the connection timeout for the default HTTP client. * + *

{@link Duration#ZERO} disables the connection timeout. Positive values shorter than one + * millisecond are rounded up to one millisecond, and values at or above {@code + * Integer.MAX_VALUE} milliseconds are capped at that value. This setting is ignored when a + * custom transport is supplied. + * * @param connectTimeout non-negative timeout * @return this builder */ @@ -116,6 +121,12 @@ public D1ClientBuilder connectTimeout(Duration connectTimeout) { /** * Sets the per-request timeout. * + *

The duration is included unchanged in each {@link D1TransportRequest}. The default transport + * uses it as the {@code HttpURLConnection} read timeout: {@link Duration#ZERO} disables the read + * timeout, positive values shorter than one millisecond are rounded up to one millisecond, and + * values at or above {@code Integer.MAX_VALUE} milliseconds are capped at that value. A custom + * transport is responsible for enforcing the supplied request timeout. + * * @param requestTimeout non-negative timeout * @return this builder */ diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/D1TransportRequest.java b/src/main/java/io/github/xxvw/cloudflare/d1/D1TransportRequest.java index 80b3f0c..ebc4096 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/D1TransportRequest.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/D1TransportRequest.java @@ -78,7 +78,11 @@ public String body() { } /** - * Request timeout. + * Request timeout supplied by the client builder. + * + *

{@link Duration#ZERO} means no timeout. The default transport uses this value as a read + * timeout; custom transports should enforce it where their HTTP stack supports request + * timeouts. * * @return request timeout */ diff --git a/src/main/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1Transport.java b/src/main/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1Transport.java index 4253b90..8ec3121 100644 --- a/src/main/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1Transport.java +++ b/src/main/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1Transport.java @@ -15,6 +15,8 @@ import java.util.Map; public final class HttpURLConnectionD1Transport implements D1Transport { + private static final Duration MAX_TIMEOUT = Duration.ofMillis(Integer.MAX_VALUE); + private final Duration connectTimeout; public HttpURLConnectionD1Transport(Duration connectTimeout) { @@ -54,15 +56,15 @@ public D1TransportResponse send(D1TransportRequest request) throws IOException { } } - private static int timeoutMillis(Duration timeout) { - if (timeout == null) { + static int timeoutMillis(Duration timeout) { + if (timeout == null || timeout.isZero()) { return 0; } - long millis = timeout.toMillis(); - if (millis > Integer.MAX_VALUE) { + if (timeout.compareTo(MAX_TIMEOUT) >= 0) { return Integer.MAX_VALUE; } - return (int) millis; + long millis = timeout.toMillis(); + return millis == 0 && !timeout.isNegative() ? 1 : (int) millis; } private static String readBody(InputStream input) throws IOException { diff --git a/src/test/java/io/github/xxvw/cloudflare/d1/D1EnvironmentBuilderTest.java b/src/test/java/io/github/xxvw/cloudflare/d1/D1EnvironmentBuilderTest.java new file mode 100644 index 0000000..2753f24 --- /dev/null +++ b/src/test/java/io/github/xxvw/cloudflare/d1/D1EnvironmentBuilderTest.java @@ -0,0 +1,137 @@ +package io.github.xxvw.cloudflare.d1; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import java.time.Duration; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class D1EnvironmentBuilderTest { + + @Test + void builderFromEnvRejectsMissingAndBlankRequiredValues() { + Map missingAccountEnv = validEnv(); + missingAccountEnv.remove("CLOUDFLARE_ACCOUNT_ID"); + + assertThatThrownBy(() -> D1Client.builderFromEnv(missingAccountEnv::get)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("CLOUDFLARE_ACCOUNT_ID environment variable must be set"); + + Map blankDatabaseEnv = validEnv(); + blankDatabaseEnv.put("D1_DATABASE_ID", " "); + + assertThatThrownBy(() -> D1AsyncClient.builderFromEnv(blankDatabaseEnv::get)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("D1_DATABASE_ID environment variable must be set"); + } + + @Test + void syncEnvironmentBuilderAllowsTransportRetryTimeoutAndMapperConfiguration() { + AtomicInteger calls = new AtomicInteger(); + AtomicReference captured = new AtomicReference<>(); + D1Transport transport = request -> { + captured.set(request); + if (calls.incrementAndGet() == 1) { + return new D1TransportResponse( + 500, + Collections.>emptyMap(), + "{\"success\":false,\"errors\":[],\"messages\":[]}"); + } + return new D1TransportResponse( + 200, + Collections.>emptyMap(), + successfulRows("[{\"user_id\":7}]")); + }; + D1RetryPolicy retryPolicy = D1RetryPolicy.builder() + .maxRetries(1) + .baseDelay(Duration.ZERO) + .maxDelay(Duration.ZERO) + .jitter(false) + .build(); + ObjectMapper mapper = + new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE); + + try (D1Client client = D1Client.builderFromEnv(validEnv()::get) + .baseUrl("https://example.com/client/v4") + .connectTimeout(Duration.ofNanos(1)) + .requestTimeout(Duration.ofSeconds(12)) + .retryPolicy(retryPolicy) + .transport(transport) + .objectMapper(mapper) + .build()) { + List rows = client.query("SELECT user_id FROM users", EnvironmentRow.class); + + assertThat(rows).extracting(row -> row.userId).containsExactly(7); + } + + assertThat(calls).hasValue(2); + assertThat(captured.get().uri().toString()) + .isEqualTo( + "https://example.com/client/v4/accounts/test-account-id/d1/database/" + + "test-database-id/query"); + assertThat(captured.get().headers()) + .containsEntry("Authorization", "Bearer test-token"); + assertThat(captured.get().timeout()).isEqualTo(Duration.ofSeconds(12)); + } + + @Test + void asyncEnvironmentBuilderAllowsExecutorConfiguration() throws Exception { + AtomicInteger executions = new AtomicInteger(); + AtomicReference captured = new AtomicReference<>(); + Executor executor = command -> { + executions.incrementAndGet(); + command.run(); + }; + D1Transport transport = request -> { + captured.set(request); + return new D1TransportResponse( + 200, + Collections.>emptyMap(), + successfulRows("[]")); + }; + + try (D1AsyncClient client = D1AsyncClient.builderFromEnv(validEnv()::get) + .baseUrl("https://example.com/client/v4") + .requestTimeout(Duration.ofSeconds(9)) + .retryPolicy(D1RetryPolicy.none()) + .transport(transport) + .executor(executor) + .build()) { + D1Result result = client.queryAsync("SELECT 1").get(1, TimeUnit.SECONDS); + + assertThat(result.success()).isTrue(); + } + + assertThat(executions).hasValue(1); + assertThat(captured.get().headers()) + .containsEntry("Authorization", "Bearer test-token"); + assertThat(captured.get().timeout()).isEqualTo(Duration.ofSeconds(9)); + } + + private static Map validEnv() { + Map env = new LinkedHashMap<>(); + env.put("CLOUDFLARE_ACCOUNT_ID", "test-account-id"); + env.put("D1_DATABASE_ID", "test-database-id"); + env.put("CLOUDFLARE_API_TOKEN", "test-token"); + return env; + } + + private static String successfulRows(String rows) { + return "{\"success\":true,\"result\":[{\"success\":true,\"results\":" + rows + + ",\"meta\":{}}],\"errors\":[],\"messages\":[]}"; + } + + static final class EnvironmentRow { + public int userId; + } +} diff --git a/src/test/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1TransportTest.java b/src/test/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1TransportTest.java index d114ff8..9c7fe09 100644 --- a/src/test/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1TransportTest.java +++ b/src/test/java/io/github/xxvw/cloudflare/d1/internal/HttpURLConnectionD1TransportTest.java @@ -80,6 +80,21 @@ void clampsOversizedTimeoutsAndAcceptsNullConnectTimeout() throws Exception { assertThat(response.statusCode()).isEqualTo(200); } + @Test + void convertsConnectAndRequestTimeoutsWithoutLosingPositiveDurations() { + assertThat(HttpURLConnectionD1Transport.timeoutMillis(null)).isZero(); + assertThat(HttpURLConnectionD1Transport.timeoutMillis(Duration.ZERO)).isZero(); + assertThat(HttpURLConnectionD1Transport.timeoutMillis(Duration.ofNanos(1))).isOne(); + assertThat(HttpURLConnectionD1Transport.timeoutMillis(Duration.ofNanos(999_999))).isOne(); + assertThat(HttpURLConnectionD1Transport.timeoutMillis(Duration.ofMillis(250))).isEqualTo(250); + assertThat(HttpURLConnectionD1Transport.timeoutMillis( + Duration.ofMillis(Integer.MAX_VALUE - 1L))).isEqualTo(Integer.MAX_VALUE - 1); + assertThat(HttpURLConnectionD1Transport.timeoutMillis( + Duration.ofMillis(Integer.MAX_VALUE))).isEqualTo(Integer.MAX_VALUE); + assertThat(HttpURLConnectionD1Transport.timeoutMillis( + Duration.ofSeconds(Long.MAX_VALUE))).isEqualTo(Integer.MAX_VALUE); + } + @Test void emptyErrorBodiesReturnAnEmptyString() throws Exception { server.enqueue(new MockResponse.Builder().code(500).build()); From cc58c98df5c57b8c04120b22a34aa54026560eab Mon Sep 17 00:00:00 2001 From: xxvw Date: Mon, 27 Jul 2026 11:52:30 +0900 Subject: [PATCH 2/2] docs: consolidate unreleased changelog entries --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9311b6d..1360a88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ This project follows Semantic Versioning. ### Added +- Added `D1RawBatchException` with the first failed item index and immutable partial raw results for + item-level raw batch failures. - Added `D1Client.builderFromEnv()` and `D1AsyncClient.builderFromEnv()` so standard environment credentials can be combined with timeout, retry, transport, mapping, and executor configuration. @@ -19,6 +21,13 @@ This project follows Semantic Versioning. ### Fixed +- Made nested JSON maps and lists in public response and mapping-error models recursively immutable + and defensively copied. +- Invalid JSON responses with successful HTTP status codes now retain the attempted operation in + exception metadata. +- `D1AsyncClient.close()` now allows accepted queued and in-flight operations to finish before + closing the transport, rejects all post-close operations consistently, and leaves caller-supplied + executors open. - Preserved positive sub-millisecond default transport timeouts by rounding them up to one millisecond, while keeping zero unlimited and safely capping oversized durations. - Added the missing Maven `compile` phase to parameterized quickstart commands.