Skip to content
Open
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
177 changes: 165 additions & 12 deletions sdk-core/api/sdk-core.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ public interface CallContext : AutoCloseable {
*/
public val callKey: String

/**
* Per-call options overlay for this chain: a per-phase timeout overlay, a
* response-validation decision, an optional ad-hoc credential, and typed extension
* attributes. Minted once at the head of the chain ([DispatchContext]) and carried forward
* unchanged by every promotion, so the dispatch / request / exchange phases all observe the
* same overrides. Defaults to [CallOptions.NONE] (inherit the client configuration).
*/
public val callOptions: CallOptions

/**
* Removes this context's chain from [ContextStore], but only if this context is still
* the registered occupant of the slot. Eviction is conditional on identity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2026 dexpace and Omar Aljarrah
*
* Licensed under the MIT License. See LICENSE in the project root.
* SPDX-License-Identifier: MIT
*/

package org.dexpace.sdk.core.http.context

/**
* Typed key for an ad-hoc per-call override carried in [CallOptions.attributes].
*
* The well-known per-call knobs (timeout, response validation, credential) are first-class
* fields on [CallOptions]; this key type is the open extension channel for everything else a
* caller — or a future generated operation — may want to thread through the context chain
* without growing the core options surface.
*
* Identity is *by instance*, not by [name]: two keys with the same name are distinct so that
* independently-defined extensions never collide. Declare each key once as a `private`/`public`
* constant and reuse it. [name] exists purely for diagnostics and a readable [toString].
*
* The phantom type parameter [T] ties a key to the value type stored under it, so
* [CallOptions.get] returns a correctly-typed value with no cast at the call site.
*
* @param T The type of value stored under this key.
* @property name Human-readable label used only for diagnostics.
*/
public class CallOption<T : Any>(
public val name: String,
) {
override fun toString(): String = "CallOption($name)"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright (c) 2026 dexpace and Omar Aljarrah
*
* Licensed under the MIT License. See LICENSE in the project root.
* SPDX-License-Identifier: MIT
*/

package org.dexpace.sdk.core.http.context

import org.dexpace.sdk.core.http.auth.Credential

/**
* Immutable, per-call options bag threaded through the context promotion chain
* ([DispatchContext] -> [RequestContext] -> [ExchangeContext]).
*
* `CallOptions` lets a caller override selected client-level behaviour for a *single* call
* without mutating the shared client: a per-call timeout overlay, a response-validation
* decision, an ad-hoc credential, and an open set of typed extension [attributes]. The
* client supplies a baseline; the call site supplies overrides; [applyDefaults] merges the
* two with per-field precedence (the receiver wins, falling back to the supplied defaults).
*
* ## "Unset" without nullability
*
* The well-known knobs are non-`null` with an inherit-shaped default — [CallTimeout.NONE] and
* [ResponseValidation.INHERIT] — so reading them never forces a `!!`. The one genuinely
* optional field, [credentialOverride], is `null` when absent; that is a real "no override"
* absence and is read null-safely, not with `!!`.
*
* ## Merge semantics
*
* [applyDefaults] resolves each field independently:
* - [timeout] overlays per phase via [CallTimeout.applyDefaults].
* - [responseValidation] coalesces [ResponseValidation.INHERIT] to the default.
* - [credentialOverride] takes the receiver's value when present, else the default's.
* - [attributes] is a union with the receiver's entries winning on key collisions.
*
* [NONE] (every field at its inherit default, no attributes) is the identity element:
* `x.applyDefaults(NONE) == x` and `NONE.applyDefaults(x) == x`.
*
* ## Thread-safety
*
* Immutable; the attribute map is defensively copied at build time and exposed read-only.
* Instances are freely shareable. [Builder] is single-thread / externally guarded.
*
* @property timeout Per-phase timeout overlay; [CallTimeout.NONE] inherits every phase.
* @property responseValidation Response-validation override; [ResponseValidation.INHERIT] defers to the client.
* @property credentialOverride Ad-hoc credential for this call, or `null` to use the client credential.
* @property attributes Read-only typed extension overrides keyed by [CallOption].
*/
@ConsistentCopyVisibility
public data class CallOptions private constructor(
val timeout: CallTimeout,
val responseValidation: ResponseValidation,
val credentialOverride: Credential?,
val attributes: Map<CallOption<*>, Any>,
) {
/**
* Returns `true` when this option set overrides nothing — every field is at its inherit
* default and no extension attributes are present. Equivalent to `this == NONE`.
*/
public fun isEmpty(): Boolean =
timeout.isEmpty() &&
responseValidation == ResponseValidation.INHERIT &&
credentialOverride == null &&
attributes.isEmpty()

/**
* Returns the value stored under [key], or `null` if absent. The result is typed to the
* key's value type — no cast at the call site.
*/
@Suppress("UNCHECKED_CAST")
public fun <T : Any> get(key: CallOption<T>): T? = attributes[key] as T?

/**
* Returns the value stored under [key], or [fallback] if absent.
*/
public fun <T : Any> getOrDefault(
key: CallOption<T>,
fallback: T,
): T = get(key) ?: fallback

/**
* Returns `true` if a value is present under [key].
*/
public fun contains(key: CallOption<*>): Boolean = attributes.containsKey(key)

/**
* Overlays this option set onto [defaults], resolving each field independently (see the
* type KDoc for the per-field rules). The receiver always has priority; [defaults] fills
* in whatever the receiver leaves at its inherit value.
*
* @param defaults The lower-priority option set.
* @return A merged option set; returns `this` unchanged when the merge produces no difference.
*/
public fun applyDefaults(defaults: CallOptions): CallOptions {
val mergedAttributes =
if (defaults.attributes.isEmpty()) {
attributes
} else {
LinkedHashMap<CallOption<*>, Any>(defaults.attributes).apply { putAll(attributes) }
}
val merged =
CallOptions(
timeout = timeout.applyDefaults(defaults.timeout),
responseValidation = responseValidation.applyDefault(defaults.responseValidation),
credentialOverride = credentialOverride ?: defaults.credentialOverride,
attributes = mergedAttributes,
)
return if (merged == this) this else merged
}

/**
* Returns a new [Builder] pre-filled with this option set's fields.
*/
public fun newBuilder(): Builder = Builder(this)

/**
* Mutable builder for [CallOptions]. Implements [org.dexpace.sdk.core.generics.Builder] so
* it composes with builder-folding helpers.
*/
public class Builder : org.dexpace.sdk.core.generics.Builder<CallOptions> {
private var timeout: CallTimeout = CallTimeout.NONE
private var responseValidation: ResponseValidation = ResponseValidation.INHERIT
private var credentialOverride: Credential? = null
private val attributes: LinkedHashMap<CallOption<*>, Any> = LinkedHashMap()

/** Creates an empty builder (every field inherits). */
public constructor()

/** Creates a builder initialized from [options]. */
public constructor(options: CallOptions) {
this.timeout = options.timeout
this.responseValidation = options.responseValidation
this.credentialOverride = options.credentialOverride
this.attributes.putAll(options.attributes)
}

/** Sets the per-phase timeout overlay. Defaults to [CallTimeout.NONE]. */
public fun timeout(timeout: CallTimeout): Builder = apply { this.timeout = timeout }

/** Sets the response-validation override. Defaults to [ResponseValidation.INHERIT]. */
public fun responseValidation(responseValidation: ResponseValidation): Builder =
apply { this.responseValidation = responseValidation }

/** Sets the ad-hoc credential override. Pass `null` to use the client credential. */
public fun credentialOverride(credentialOverride: Credential?): Builder =
apply { this.credentialOverride = credentialOverride }

/**
* Stores [value] under the typed [key], replacing any previous value for that key.
*/
public fun <T : Any> attribute(
key: CallOption<T>,
value: T,
): Builder = apply { attributes[key] = value }

/**
* Removes any value previously stored under [key].
*/
public fun removeAttribute(key: CallOption<*>): Builder = apply { attributes.remove(key) }

/**
* Builds the [CallOptions], taking a defensive, read-only copy of the attribute map.
*/
override fun build(): CallOptions =
CallOptions(
timeout = timeout,
responseValidation = responseValidation,
credentialOverride = credentialOverride,
attributes = if (attributes.isEmpty()) emptyMap() else LinkedHashMap(attributes),
)
}

public companion object {
/** The empty option set: every field inherits and no attributes are present. */
@JvmField
public val NONE: CallOptions =
CallOptions(
timeout = CallTimeout.NONE,
responseValidation = ResponseValidation.INHERIT,
credentialOverride = null,
attributes = emptyMap(),
)

/** Returns a fresh empty builder. Java-friendly `CallOptions.builder()` entry point. */
@JvmStatic
public fun builder(): Builder = Builder()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2026 dexpace and Omar Aljarrah
*
* Licensed under the MIT License. See LICENSE in the project root.
* SPDX-License-Identifier: MIT
*/

package org.dexpace.sdk.core.http.context

import java.time.Duration

/**
* Per-phase timeout overlay carried by [CallOptions].
*
* Each phase ([connect], [write], [read], [call]) is an *overlay*: a non-`null` value
* overrides the inherited client-level timeout for that phase, while `null` means
* "inherit — leave the client default in place". This is deliberately distinct from the
* absolute, fully-resolved timeouts a transport ultimately applies; a [CallTimeout] only
* expresses the per-call *deltas* a caller wants layered on top of the client configuration.
*
* The four phases mirror the standard HTTP client timeout surface:
* - [connect] — establishing the TCP / TLS connection.
* - [write] — streaming the request body to the server.
* - [read] — waiting for / streaming the response.
* - [call] — an end-to-end ceiling spanning the whole exchange (retries excluded).
*
* ## Merge semantics
*
* [applyDefaults] performs per-field null-coalescing: a phase set on the receiver wins;
* otherwise the same phase from the supplied defaults is used. Merging is associative in the
* usual overlay sense — `a.applyDefaults(b).applyDefaults(c)` resolves each phase to the
* first non-`null` value in `a, b, c`.
*
* ## Thread-safety
*
* Immutable; instances are freely shareable. [Builder] is single-thread / externally guarded.
*
* @property connect Connect-phase overlay, or `null` to inherit.
* @property write Write-phase overlay, or `null` to inherit.
* @property read Read-phase overlay, or `null` to inherit.
* @property call Whole-call overlay, or `null` to inherit.
*/
@ConsistentCopyVisibility
public data class CallTimeout private constructor(
val connect: Duration?,
val write: Duration?,
val read: Duration?,
val call: Duration?,
) {
/**
* Returns `true` when no phase is overridden, i.e. this overlay contributes nothing and
* the inherited client timeouts apply unchanged. Equivalent to `this == NONE`.
*/
public fun isEmpty(): Boolean = connect == null && write == null && read == null && call == null

/**
* Overlays this timeout onto [defaults]: for each phase, this overlay's value wins when
* set, otherwise the corresponding value from [defaults] is taken. The result therefore
* resolves each phase to the first non-`null` of `(this, defaults)`.
*
* @param defaults The lower-priority overlay supplying values for phases this one leaves unset.
* @return A merged overlay; returns `this` unchanged when the merge produces no difference.
*/
public fun applyDefaults(defaults: CallTimeout): CallTimeout {
val merged =
CallTimeout(
connect = connect ?: defaults.connect,
write = write ?: defaults.write,
read = read ?: defaults.read,
call = call ?: defaults.call,
)
return if (merged == this) this else merged
}

/**
* Returns a new [Builder] pre-filled with this overlay's phases.
*/
public fun newBuilder(): Builder = Builder(this)

/**
* Mutable builder for [CallTimeout]. Implements [org.dexpace.sdk.core.generics.Builder] so
* it composes with builder-folding helpers.
*/
public class Builder : org.dexpace.sdk.core.generics.Builder<CallTimeout> {
private var connect: Duration? = null
private var write: Duration? = null
private var read: Duration? = null
private var call: Duration? = null

/** Creates an empty builder (every phase inherits). */
public constructor()

/** Creates a builder initialized from [timeout]. */
public constructor(timeout: CallTimeout) {
this.connect = timeout.connect
this.write = timeout.write
this.read = timeout.read
this.call = timeout.call
}

/** Overrides the connect-phase timeout. Pass `null` to inherit. */
public fun connect(connect: Duration?): Builder = apply { this.connect = connect }

/** Overrides the write-phase timeout. Pass `null` to inherit. */
public fun write(write: Duration?): Builder = apply { this.write = write }

/** Overrides the read-phase timeout. Pass `null` to inherit. */
public fun read(read: Duration?): Builder = apply { this.read = read }

/** Overrides the whole-call timeout. Pass `null` to inherit. */
public fun call(call: Duration?): Builder = apply { this.call = call }

/**
* Builds the [CallTimeout].
*
* @throws IllegalArgumentException If any set phase is negative.
*/
override fun build(): CallTimeout {
requireNonNegative("connect", connect)
requireNonNegative("write", write)
requireNonNegative("read", read)
requireNonNegative("call", call)
return CallTimeout(connect = connect, write = write, read = read, call = call)
}

private fun requireNonNegative(
phase: String,
value: Duration?,
) {
require(value == null || !value.isNegative) { "$phase timeout must not be negative" }
}
}

public companion object {
/** The empty overlay: every phase inherits. */
@JvmField
public val NONE: CallTimeout = CallTimeout(connect = null, write = null, read = null, call = null)

/** Returns a fresh empty builder. Java-friendly `CallTimeout.builder()` entry point. */
@JvmStatic
public fun builder(): Builder = Builder()

/**
* Convenience for a [call]-only overlay (the most common per-call knob). Equivalent to
* `builder().call(duration).build()`.
*/
@JvmStatic
public fun ofCall(call: Duration): CallTimeout = Builder().call(call).build()
}
}
Loading
Loading