Skip to content

feat: add copy-on-write derivation to Configuration#161

Merged
OmarAlJarrah merged 7 commits into
mainfrom
feat/configuration-copy-on-write-derivation
Jun 20, 2026
Merged

feat: add copy-on-write derivation to Configuration#161
OmarAlJarrah merged 7 commits into
mainfrom
feat/configuration-copy-on-write-derivation

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jun 20, 2026

Copy link
Copy Markdown
Member

Problem

Configuration is immutable and built once via ConfigurationBuilder, but there was no supported way to take an existing instance and produce a slightly-different one. Callers wanting "the same configuration, but with LOG_LEVEL=DEBUG" had to reconstruct it from scratch and manually re-thread the env/property lookup seams — easy to get subtly wrong, and impossible for code that only holds a built Configuration (it can't see the original overrides or sources).

Change

Add copy-on-write derivation to Configuration, and round out its construction surface:

  • Configuration.derive(Consumer<ConfigurationBuilder>) — derive a new Configuration by applying a mutator to a builder prefilled from the receiver, then building. The receiver is left untouched. This is the common derive-in-one-call path.
  • Configuration.newBuilder() — returns the same prefilled builder, for callers that prefer to thread it through other builder-folding code before build().
  • Configuration.builder()@JvmStatic factory returning a fresh empty builder, matching the builder() idiom every other SDK model exposes (Request, Response, Headers, RequestConditions, RetrySettings, ClientIdentityStep, IdempotencyKeyStep) so Java callers no longer have to reach for new ConfigurationBuilder().
  • ConfigurationBuilder now implements Builder<Configuration> and gains a prefilled constructor(Configuration), bringing it in line with the rest of the SDK's builders.

Together these complete the construction surface: builder() for from-scratch, newBuilder() / derive() for copy-on-write derivation from an existing instance.

Semantics are copy-on-write in the value sense: the override map is copied defensively so the original and derived instances never alias the same mutable state, while the envSource / propsSource lookup functions are shared by reference (they are pure read seams and are never mutated). A mutator that changes nothing yields an independent instance equal in behaviour to the original.

The method is named derive (rather than withOptions) because it names the operation directly — deriving a reconfigured copy — instead of introducing an "options" noun that appears nowhere else in the configuration API. newBuilder() (rather than toBuilder()) matches the prefilled-builder accessor every other model already exposes.

The three derivation read seams (overrides / envSource / propsSource) are exposed to the builder as @get:JvmSynthetic internal, so the prefilled constructor can read them without leaking a Java-callable accessor onto the public surface.

Tests

Cases covering: the builder() factory returning a fresh empty builder; adding a new override on the derived copy; the original staying unchanged; overriding an existing key without mutating the original; inherited env/property seams, with an explicit override winning over both an inherited property and an inherited env seam; seams shared by reference; source replacement inside the mutator detaching only the derived copy; the empty-mutator independent-but-equivalent case; empty-env skip-to-sysprop semantics surviving derivation; derivation from an override-less base; chained derivation accumulating overrides while each level stays independent; the null-mutator NullPointerException contract; and two builder-isolation cases (post-build mutation, and two independent derivations from one base not seeing each other's overrides).

API

Public-API additions; sdk-core.api snapshot regenerated via apiDump.

Gated build

./gradlew :sdk-core:test :sdk-core:apiCheck :sdk-core:ktlintCheck :sdk-core:detekt --no-daemon

Result: BUILD SUCCESSFUL.

Closes #60.

Add Configuration.newBuilder() and Configuration.withOptions(Consumer) so a
caller can derive a reconfigured copy of an existing Configuration without
mutating the original. The override map is copied defensively while the
env/property lookup seams are shared by reference, so a derived instance never
aliases the receiver's mutable state. withOptions covers the common
derive-in-one-call case; newBuilder exposes the prefilled builder for code that
threads it through other builder-folding steps before build().

ConfigurationBuilder now implements Builder<Configuration> and gains a
prefilled constructor, bringing it in line with the newBuilder() pattern the
other SDK builders (Request, Response, Headers, RequestConditions, ...) already
follow.
Replace the three internal accessor methods (overridesSnapshot/envSource/
propsSource) with internal-val backing fields read directly by the prefilled
ConfigurationBuilder constructor. This removes the property/method name
collision and drops a redundant defensive copy of the override map (the
builder's putAll already copies into its own mutable map).

Add a test pinning the documented contract that the env/property read seams
are inherited by reference, not copied.
…vation coverage

Rename the copy-on-write derivation entry point from withOptions to derive.
The name states the operation — deriving a reconfigured copy — instead of
introducing an "options" noun that appears nowhere else in the configuration
API, and it pairs cleanly with newBuilder.

Constrain the derivation read seams (overrides/envSource/propsSource) with
@get:JvmSynthetic so their internal accessors stay off the Java-callable
surface, matching the SDK's internal-seam convention.

Surface derive/newBuilder in the class-level KDoc summary, and add tests for
chained derivation and intermediate independence, the null-mutator NPE
contract, an override beating an inherited env seam, source replacement
detaching only the derived copy, empty-env skip-to-sysprop inheritance, and
derivation from an override-less base.
Every other SDK value model exposes a @JvmStatic builder() entry point
(Request, Response, Headers, RequestConditions, RetrySettings,
ClientIdentityStep, IdempotencyKeyStep). Configuration was the lone
exception, forcing Java callers to write `new ConfigurationBuilder()`.

Add `Configuration.builder()` returning a fresh empty ConfigurationBuilder,
completing the construction surface: builder() for from-scratch,
newBuilder()/derive() for copy-on-write derivation from an existing instance.
…tion

Configuration derivation could previously only add or shadow overrides — there was
no supported way to drop an inherited explicit override and let a key fall back to the
environment / system-property layers again. Code holding a derived Configuration that
wanted "the same config, but stop forcing LOG_LEVEL" had to rebuild from scratch and
re-thread the lookup seams.

Add ConfigurationBuilder.remove(name), the inverse of put(), following the existing
Headers.Builder.remove convention (returns the builder for chaining, no-op when the key
is absent). It drops only the override layer: a later get(name) falls through to the
environment variable, then the normalized system property, then the caller's default. It
does not force the key to null and does not suppress an inherited env/property value for
that key. This lets Configuration.derive { it.remove(k) } un-pin an override inherited
from the source while leaving the source unchanged.

Tests cover builder-level removal (existing key, absent-key no-op, fall-through to the env
and property seams, chaining, null-name NPE) and derivation-level removal (un-pinning
without mutating the base, fall-through to the inherited env and property seams). Also
tidies two KDoc lines on the surrounding construction surface.
…esponseBridge

closeQuietly intentionally discards any exception from response.close(). Rename the
bound-but-unused catch parameter to the anonymous `_` to state that intent directly and
match idiomatic Kotlin for ignored bindings.
@OmarAlJarrah OmarAlJarrah merged commit a27775b into main Jun 20, 2026
1 check passed
@OmarAlJarrah OmarAlJarrah deleted the feat/configuration-copy-on-write-derivation branch June 20, 2026 15:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate withOptions(Consumer<Builder>) returning a new immutable client

1 participant