diff --git a/sdk-core/api/sdk-core.api b/sdk-core/api/sdk-core.api index 7ebcc312..3e67687a 100644 --- a/sdk-core/api/sdk-core.api +++ b/sdk-core/api/sdk-core.api @@ -503,6 +503,7 @@ public final class org/dexpace/sdk/core/http/common/RequestConditions$Companion public abstract interface class org/dexpace/sdk/core/http/context/CallContext : java/lang/AutoCloseable { public fun close ()V public abstract fun getCallKey ()Ljava/lang/String; + public abstract fun getCallOptions ()Lorg/dexpace/sdk/core/http/context/CallOptions; public abstract fun getInstrumentationContext ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; } @@ -510,6 +511,90 @@ public final class org/dexpace/sdk/core/http/context/CallContext$DefaultImpls { public static fun close (Lorg/dexpace/sdk/core/http/context/CallContext;)V } +public final class org/dexpace/sdk/core/http/context/CallOption { + public fun (Ljava/lang/String;)V + public final fun getName ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/context/CallOptions { + public static final field Companion Lorg/dexpace/sdk/core/http/context/CallOptions$Companion; + public static final field NONE Lorg/dexpace/sdk/core/http/context/CallOptions; + public synthetic fun (Lorg/dexpace/sdk/core/http/context/CallTimeout;Lorg/dexpace/sdk/core/http/context/ResponseValidation;Lorg/dexpace/sdk/core/http/auth/Credential;Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun applyDefaults (Lorg/dexpace/sdk/core/http/context/CallOptions;)Lorg/dexpace/sdk/core/http/context/CallOptions; + public static final fun builder ()Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public final fun component1 ()Lorg/dexpace/sdk/core/http/context/CallTimeout; + public final fun component2 ()Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public final fun component3 ()Lorg/dexpace/sdk/core/http/auth/Credential; + public final fun component4 ()Ljava/util/Map; + public final fun contains (Lorg/dexpace/sdk/core/http/context/CallOption;)Z + public fun equals (Ljava/lang/Object;)Z + public final fun get (Lorg/dexpace/sdk/core/http/context/CallOption;)Ljava/lang/Object; + public final fun getAttributes ()Ljava/util/Map; + public final fun getCredentialOverride ()Lorg/dexpace/sdk/core/http/auth/Credential; + public final fun getOrDefault (Lorg/dexpace/sdk/core/http/context/CallOption;Ljava/lang/Object;)Ljava/lang/Object; + public final fun getResponseValidation ()Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public final fun getTimeout ()Lorg/dexpace/sdk/core/http/context/CallTimeout; + public fun hashCode ()I + public final fun isEmpty ()Z + public final fun newBuilder ()Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/context/CallOptions$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public fun (Lorg/dexpace/sdk/core/http/context/CallOptions;)V + public final fun attribute (Lorg/dexpace/sdk/core/http/context/CallOption;Ljava/lang/Object;)Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/http/context/CallOptions; + public final fun credentialOverride (Lorg/dexpace/sdk/core/http/auth/Credential;)Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public final fun removeAttribute (Lorg/dexpace/sdk/core/http/context/CallOption;)Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public final fun responseValidation (Lorg/dexpace/sdk/core/http/context/ResponseValidation;)Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; + public final fun timeout (Lorg/dexpace/sdk/core/http/context/CallTimeout;)Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; +} + +public final class org/dexpace/sdk/core/http/context/CallOptions$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/http/context/CallOptions$Builder; +} + +public final class org/dexpace/sdk/core/http/context/CallTimeout { + public static final field Companion Lorg/dexpace/sdk/core/http/context/CallTimeout$Companion; + public static final field NONE Lorg/dexpace/sdk/core/http/context/CallTimeout; + public synthetic fun (Ljava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Ljava/time/Duration;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun applyDefaults (Lorg/dexpace/sdk/core/http/context/CallTimeout;)Lorg/dexpace/sdk/core/http/context/CallTimeout; + public static final fun builder ()Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public final fun component1 ()Ljava/time/Duration; + public final fun component2 ()Ljava/time/Duration; + public final fun component3 ()Ljava/time/Duration; + public final fun component4 ()Ljava/time/Duration; + public fun equals (Ljava/lang/Object;)Z + public final fun getCall ()Ljava/time/Duration; + public final fun getConnect ()Ljava/time/Duration; + public final fun getRead ()Ljava/time/Duration; + public final fun getWrite ()Ljava/time/Duration; + public fun hashCode ()I + public final fun isEmpty ()Z + public final fun newBuilder ()Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public static final fun ofCall (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout; + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/http/context/CallTimeout$Builder : org/dexpace/sdk/core/generics/Builder { + public fun ()V + public fun (Lorg/dexpace/sdk/core/http/context/CallTimeout;)V + public synthetic fun build ()Ljava/lang/Object; + public fun build ()Lorg/dexpace/sdk/core/http/context/CallTimeout; + public final fun call (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public final fun connect (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public final fun read (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public final fun write (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; +} + +public final class org/dexpace/sdk/core/http/context/CallTimeout$Companion { + public final fun builder ()Lorg/dexpace/sdk/core/http/context/CallTimeout$Builder; + public final fun ofCall (Ljava/time/Duration;)Lorg/dexpace/sdk/core/http/context/CallTimeout; +} + public final class org/dexpace/sdk/core/http/context/ContextStore { public static final field INSTANCE Lorg/dexpace/sdk/core/http/context/ContextStore; public final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/context/CallContext; @@ -521,15 +606,17 @@ public final class org/dexpace/sdk/core/http/context/ContextStore { public final class org/dexpace/sdk/core/http/context/DispatchContext : org/dexpace/sdk/core/http/context/CallContext { public static final field Companion Lorg/dexpace/sdk/core/http/context/DispatchContext$Companion; - public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;)V - public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)V + public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public final fun component1 ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public final fun component2 ()Ljava/lang/String; - public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/context/DispatchContext; - public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/DispatchContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/DispatchContext; + public final fun component3 ()Lorg/dexpace/sdk/core/http/context/CallOptions; + public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)Lorg/dexpace/sdk/core/http/context/DispatchContext; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/DispatchContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/DispatchContext; public fun equals (Ljava/lang/Object;)Z public fun getCallKey ()Ljava/lang/String; + public fun getCallOptions ()Lorg/dexpace/sdk/core/http/context/CallOptions; public fun getInstrumentationContext ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public fun hashCode ()I public final fun toRequestContext (Lorg/dexpace/sdk/core/http/request/Request;)Lorg/dexpace/sdk/core/http/context/RequestContext; @@ -541,17 +628,19 @@ public final class org/dexpace/sdk/core/http/context/DispatchContext$Companion { } public final class org/dexpace/sdk/core/http/context/ExchangeContext : org/dexpace/sdk/core/http/context/CallContext { - public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;)V - public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)V + public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public final fun component1 ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public final fun component2 ()Lorg/dexpace/sdk/core/http/request/Request; public final fun component3 ()Lorg/dexpace/sdk/core/http/response/Response; public final fun component4 ()Ljava/lang/String; - public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/context/ExchangeContext; - public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/ExchangeContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/ExchangeContext; + public final fun component5 ()Lorg/dexpace/sdk/core/http/context/CallOptions; + public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)Lorg/dexpace/sdk/core/http/context/ExchangeContext; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/ExchangeContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/http/response/Response;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/ExchangeContext; public fun equals (Ljava/lang/Object;)Z public fun getCallKey ()Ljava/lang/String; + public fun getCallOptions ()Lorg/dexpace/sdk/core/http/context/CallOptions; public fun getInstrumentationContext ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request; public final fun getResponse ()Lorg/dexpace/sdk/core/http/response/Response; @@ -560,16 +649,18 @@ public final class org/dexpace/sdk/core/http/context/ExchangeContext : org/dexpa } public final class org/dexpace/sdk/core/http/context/RequestContext : org/dexpace/sdk/core/http/context/CallContext { - public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;)V - public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)V + public synthetic fun (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V public final fun component1 ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public final fun component2 ()Lorg/dexpace/sdk/core/http/request/Request; public final fun component3 ()Ljava/lang/String; - public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/context/RequestContext; - public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/RequestContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/RequestContext; + public final fun component4 ()Lorg/dexpace/sdk/core/http/context/CallOptions; + public final fun copy (Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;)Lorg/dexpace/sdk/core/http/context/RequestContext; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/http/context/RequestContext;Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext;Lorg/dexpace/sdk/core/http/request/Request;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/CallOptions;ILjava/lang/Object;)Lorg/dexpace/sdk/core/http/context/RequestContext; public fun equals (Ljava/lang/Object;)Z public fun getCallKey ()Ljava/lang/String; + public fun getCallOptions ()Lorg/dexpace/sdk/core/http/context/CallOptions; public fun getInstrumentationContext ()Lorg/dexpace/sdk/core/instrumentation/InstrumentationContext; public final fun getRequest ()Lorg/dexpace/sdk/core/http/request/Request; public fun hashCode ()I @@ -577,6 +668,16 @@ public final class org/dexpace/sdk/core/http/context/RequestContext : org/dexpac public fun toString ()Ljava/lang/String; } +public final class org/dexpace/sdk/core/http/context/ResponseValidation : java/lang/Enum { + public static final field DISABLED Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public static final field ENABLED Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public static final field INHERIT Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public final fun applyDefault (Lorg/dexpace/sdk/core/http/context/ResponseValidation;)Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/context/ResponseValidation; + public static fun values ()[Lorg/dexpace/sdk/core/http/context/ResponseValidation; +} + public abstract interface class org/dexpace/sdk/core/http/paging/FirstPageFetcher { public abstract fun fetch (Lorg/dexpace/sdk/core/http/paging/PagingOptions;)Lorg/dexpace/sdk/core/http/paging/PagedResponse; } @@ -1977,6 +2078,58 @@ public abstract interface class org/dexpace/sdk/core/io/Source : java/io/Closeab public abstract fun read (Lorg/dexpace/sdk/core/io/Buffer;J)J } +public abstract interface class org/dexpace/sdk/core/operation/OperationParams { + public static final field Companion Lorg/dexpace/sdk/core/operation/OperationParams$Companion; + public static final field EMPTY_HEADERS Lorg/dexpace/sdk/core/http/common/Headers; + public static final field NONE Lorg/dexpace/sdk/core/operation/OperationParams; + public fun body ()Lorg/dexpace/sdk/core/http/request/RequestBody; + public fun headers ()Lorg/dexpace/sdk/core/http/common/Headers; + public fun pathParams ()Ljava/util/List; + public fun queryParams ()Ljava/util/List; +} + +public final class org/dexpace/sdk/core/operation/OperationParams$Companion { +} + +public final class org/dexpace/sdk/core/operation/OperationParams$DefaultImpls { + public static fun body (Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/request/RequestBody; + public static fun headers (Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/common/Headers; + public static fun pathParams (Lorg/dexpace/sdk/core/operation/OperationParams;)Ljava/util/List; + public static fun queryParams (Lorg/dexpace/sdk/core/operation/OperationParams;)Ljava/util/List; +} + +public final class org/dexpace/sdk/core/operation/PathParam { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/operation/PathParam; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/operation/PathParam;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/operation/PathParam; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/operation/QueryParam { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/operation/QueryParam; + public static synthetic fun copy$default (Lorg/dexpace/sdk/core/operation/QueryParam;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/dexpace/sdk/core/operation/QueryParam; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/dexpace/sdk/core/operation/RequestProjector { + public static final field INSTANCE Lorg/dexpace/sdk/core/operation/RequestProjector; + public static final fun project (Ljava/net/URL;Lorg/dexpace/sdk/core/http/request/Method;Ljava/lang/String;Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/request/Request; + public static final fun projectInto (Lorg/dexpace/sdk/core/http/context/DispatchContext;Ljava/net/URL;Lorg/dexpace/sdk/core/http/request/Method;Ljava/lang/String;Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/context/RequestContext; +} + public final class org/dexpace/sdk/core/pagination/CursorPaginationStrategy : org/dexpace/sdk/core/pagination/PaginationStrategy { public fun (Lkotlin/jvm/functions/Function1;)V public fun (Lkotlin/jvm/functions/Function1;Ljava/lang/String;)V diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallContext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallContext.kt index 9709b4ae..59985597 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallContext.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallContext.kt @@ -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 diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOption.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOption.kt new file mode 100644 index 00000000..c3547b43 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOption.kt @@ -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( + public val name: String, +) { + override fun toString(): String = "CallOption($name)" +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOptions.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOptions.kt new file mode 100644 index 00000000..59c1864d --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOptions.kt @@ -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, 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 get(key: CallOption): T? = attributes[key] as T? + + /** + * Returns the value stored under [key], or [fallback] if absent. + */ + public fun getOrDefault( + key: CallOption, + 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, 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 { + private var timeout: CallTimeout = CallTimeout.NONE + private var responseValidation: ResponseValidation = ResponseValidation.INHERIT + private var credentialOverride: Credential? = null + private val attributes: LinkedHashMap, 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 attribute( + key: CallOption, + 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() + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallTimeout.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallTimeout.kt new file mode 100644 index 00000000..81977795 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallTimeout.kt @@ -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 { + 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() + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/DispatchContext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/DispatchContext.kt index 921a7da6..096ca580 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/DispatchContext.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/DispatchContext.kt @@ -34,18 +34,21 @@ import java.util.concurrent.atomic.AtomicLong public data class DispatchContext( override val instrumentationContext: InstrumentationContext, override val callKey: String = mintCallKey(instrumentationContext), + override val callOptions: CallOptions = CallOptions.NONE, ) : CallContext { /** * Promotes this dispatch context into a [RequestContext] bound to [request] and stores - * the new context in [ContextStore] under this chain's [callKey]. After promotion this - * dispatch context becomes an intermediate link and must not be closed independently — - * close the returned [RequestContext] (or its own successor) instead. + * the new context in [ContextStore] under this chain's [callKey]. The chain's [callOptions] + * are carried forward unchanged. After promotion this dispatch context becomes an + * intermediate link and must not be closed independently — close the returned + * [RequestContext] (or its own successor) instead. */ public fun toRequestContext(request: Request): RequestContext = RequestContext( instrumentationContext = instrumentationContext, request = request, callKey = callKey, + callOptions = callOptions, ).also { ContextStore.set(it.callKey, it) } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ExchangeContext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ExchangeContext.kt index 9155f3ef..16571d11 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ExchangeContext.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ExchangeContext.kt @@ -31,4 +31,5 @@ public data class ExchangeContext( val request: Request, val response: Response, override val callKey: String = DispatchContext.mintCallKey(instrumentationContext), + override val callOptions: CallOptions = CallOptions.NONE, ) : CallContext diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/RequestContext.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/RequestContext.kt index f255d2ba..8966ddf0 100644 --- a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/RequestContext.kt +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/RequestContext.kt @@ -29,12 +29,14 @@ public data class RequestContext( override val instrumentationContext: InstrumentationContext, val request: Request, override val callKey: String = DispatchContext.mintCallKey(instrumentationContext), + override val callOptions: CallOptions = CallOptions.NONE, ) : CallContext { /** * Promotes this request context into an [ExchangeContext] bound to [response] and stores - * the new context in [ContextStore] under this chain's [callKey]. After promotion this - * request context becomes an intermediate link and must not be closed independently — - * close the returned [ExchangeContext] instead. + * the new context in [ContextStore] under this chain's [callKey]. The chain's [callOptions] + * are carried forward unchanged. After promotion this request context becomes an + * intermediate link and must not be closed independently — close the returned + * [ExchangeContext] instead. */ public fun toExchangeContext(response: Response): ExchangeContext = ExchangeContext( @@ -42,6 +44,7 @@ public data class RequestContext( request = request, response = response, callKey = callKey, + callOptions = callOptions, ).also { ContextStore.set(it.callKey, it) } diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ResponseValidation.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ResponseValidation.kt new file mode 100644 index 00000000..85416b6b --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ResponseValidation.kt @@ -0,0 +1,35 @@ +/* + * 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 + +/** + * Per-call override for response-status validation (the policy that turns a non-success + * status into a thrown error rather than a returned [org.dexpace.sdk.core.http.response.Response]). + * + * Modeled as a three-state enum rather than a nullable `Boolean` so the "leave the client + * default in place" case is a first-class, non-`null` value — callers never reach for `!!` + * to read it. [INHERIT] is the identity element of [CallOptions.applyDefaults]: an option set + * carrying [INHERIT] contributes nothing and lets the lower-priority value through. + */ +public enum class ResponseValidation { + /** Defer to the inherited client-level validation policy. The default for a fresh option set. */ + INHERIT, + + /** Force response validation on for this call, regardless of the client default. */ + ENABLED, + + /** Suppress response validation for this call, regardless of the client default. */ + DISABLED, + ; + + /** + * Overlays this value onto [default]: returns [default] when this is [INHERIT], otherwise + * this explicit choice. Mirrors the per-field null-coalescing the rest of [CallOptions] uses. + */ + public fun applyDefault(default: ResponseValidation): ResponseValidation = if (this == INHERIT) default else this +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/OperationParams.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/OperationParams.kt new file mode 100644 index 00000000..d2ad37fe --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/OperationParams.kt @@ -0,0 +1,79 @@ +/* + * 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.operation + +import org.dexpace.sdk.core.http.common.Headers +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.RequestBody + +/** + * SPI for projecting a single operation's inputs into the four parts of an HTTP request — + * path, query, headers, body — that feed the SDK's context chain. + * + * This is the hand-written runtime contract a future operation/params object (whether written + * by hand or emitted by a generator) implements to describe *what* a call sends, independent of + * *how* it is dispatched. The SDK turns an `OperationParams` plus an operation's [Method] and + * path template into a concrete `Request` via [RequestProjector], which then enters the + * `DispatchContext -> RequestContext -> ExchangeContext` promotion chain like any other request. + * + * Each projection is intentionally narrow and side-effect-free: + * - [pathParams] supplies `{name}` template substitutions for the operation's path. + * - [queryParams] supplies ordered, possibly-repeated query entries. + * - [headers] supplies request headers (operation-level; client/auth headers are layered later). + * - [body] supplies the request payload, or `null` for bodyless operations. + * + * Every method has an empty/none default so an implementation overrides only the parts it + * contributes — a bodyless `GET` with a couple of query params implements just [queryParams]. + * + * ## Contract + * + * Implementations should be **pure and repeatable**: projections must not mutate shared state and + * must return equivalent results across calls, since the projector may read them more than once. + * Returned collections should be treated as read-only by the caller. Values are *raw / unencoded* + * — the projector owns percent-encoding (see [PathParam], [QueryParam]). The contract says nothing + * about thread-safety of the body itself: a [RequestBody] may carry single-use stream state per + * its own contract. + */ +public interface OperationParams { + /** + * Path-template substitutions for this operation, matched by name against `{name}` + * placeholders. Defaults to none. + */ + public fun pathParams(): List = emptyList() + + /** + * Ordered query-string entries for this operation. Order and repetition are preserved by + * the projection. Defaults to none. + */ + public fun queryParams(): List = emptyList() + + /** + * Operation-level request headers. Client-level and auth headers are layered on later by the + * pipeline, so this returns only what the operation itself contributes. Defaults to empty. + */ + public fun headers(): Headers = EMPTY_HEADERS + + /** + * Request payload for this operation, or `null` for a bodyless operation (typical for + * `GET`/`HEAD`/`DELETE`). Defaults to `null`. + */ + public fun body(): RequestBody? = null + + public companion object { + /** Shared empty headers instance returned by the [headers] default. */ + @JvmField + public val EMPTY_HEADERS: Headers = Headers.Builder().build() + + /** + * The empty projection: no path params, no query, no headers, no body. Useful as a + * neutral element and for operations whose inputs live entirely in the path template. + */ + @JvmField + public val NONE: OperationParams = object : OperationParams {} + } +} diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/PathParam.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/PathParam.kt new file mode 100644 index 00000000..6e560bfd --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/PathParam.kt @@ -0,0 +1,25 @@ +/* + * 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.operation + +/** + * A single path-template substitution contributed by an [OperationParams]. + * + * [name] matches a `{name}` placeholder in an operation's path template (e.g. `/users/{id}`); + * [value] is the raw, *unencoded* replacement. The projector ([RequestProjector]) percent-encodes + * the value for the path segment when it substitutes it, so callers pass plain values. + * + * Immutable; freely shareable. + * + * @property name Placeholder name, without the surrounding braces. + * @property value Raw replacement value; percent-encoded by the projector. + */ +public data class PathParam( + val name: String, + val value: String, +) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/QueryParam.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/QueryParam.kt new file mode 100644 index 00000000..1da6f653 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/QueryParam.kt @@ -0,0 +1,30 @@ +/* + * 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.operation + +/** + * A single query-string entry contributed by an [OperationParams]. + * + * Modeled as one ordered `name`/`value` pair so a repeated parameter (`?tag=a&tag=b`) is just + * two [QueryParam] entries with the same [name] — the projection preserves order and repetition. + * [value] is the raw, *unencoded* value; the projector ([RequestProjector]) percent-encodes both + * name and value when it appends them to the URL. + * + * A `null` [value] projects a bare, valueless parameter (`?flag`) rather than `?flag=`. + * + * Immutable; freely shareable. This is the stop-gap query representation the projection uses + * until the dedicated `QueryParams` multimap lands; it deliberately stays a flat ordered pair so + * migrating to that multimap is a mechanical change. + * + * @property name Raw parameter name; percent-encoded by the projector. + * @property value Raw parameter value, or `null` for a valueless parameter. + */ +public data class QueryParam( + val name: String, + val value: String?, +) diff --git a/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/RequestProjector.kt b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/RequestProjector.kt new file mode 100644 index 00000000..460e9d97 --- /dev/null +++ b/sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/RequestProjector.kt @@ -0,0 +1,151 @@ +/* + * 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.operation + +import org.dexpace.sdk.core.http.context.DispatchContext +import org.dexpace.sdk.core.http.context.RequestContext +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.Request +import java.net.URL +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +/** + * Materializes an [OperationParams] projection into a concrete [Request]. + * + * This is the runtime primitive that bridges the operation SPI to the context chain: given a + * base URL, an HTTP [Method], a path template, and the operation's [OperationParams], it + * substitutes path placeholders, appends the query string, and stamps headers and body onto a + * fresh [Request.RequestBuilder]. The resulting [Request] enters the + * `DispatchContext -> RequestContext -> ExchangeContext` promotion chain unchanged. + * + * Encoding is owned here: [PathParam] values are percent-encoded for a path segment (so `/` in a + * value is escaped and does not introduce a new segment), and [QueryParam] names/values are + * `application/x-www-form-urlencoded`-encoded with `+` rewritten to `%20` so a literal space is + * not silently turned into a plus. Callers therefore supply raw, unencoded values. + * + * Stateless and thread-safe. + */ +public object RequestProjector { + private const val SPACE_PLUS = "+" + private const val SPACE_PCT = "%20" + + /** + * Projects [params] onto [baseUrl] for an operation with the given [method] and + * [pathTemplate], returning the request the SDK will dispatch. + * + * The [pathTemplate] is resolved against [baseUrl] (it may be absolute or, more typically, a + * relative path with `{name}` placeholders); each placeholder is replaced by the matching + * [OperationParams.pathParams] entry. The query string from [OperationParams.queryParams] is + * appended, then [OperationParams.headers] and [OperationParams.body] are applied. + * + * @param baseUrl The client base URL the [pathTemplate] resolves against. + * @param method The HTTP method for this operation. + * @param pathTemplate The operation path, with optional `{name}` placeholders. + * @param params The operation's projection. + * @return The materialized request. + * @throws IllegalArgumentException If a `{name}` placeholder has no matching path param. + */ + @JvmStatic + public fun project( + baseUrl: URL, + method: Method, + pathTemplate: String, + params: OperationParams, + ): Request { + val resolvedPath = substitutePath(pathTemplate, params.pathParams()) + val query = encodeQuery(params.queryParams()) + val spec = if (query.isEmpty()) resolvedPath else "$resolvedPath?$query" + val url = URL(baseUrl, spec) + + val builder = + Request.builder() + .method(method) + .url(url) + .headers(params.headers()) + + params.body()?.let(builder::body) + return builder.build() + } + + /** + * Projects [params] (see [project]) and promotes [dispatch] into the resulting + * [org.dexpace.sdk.core.http.context.RequestContext], registering it on the chain's store + * slot. This is the single call a thin service makes to turn an operation's inputs into a + * live request context: the [dispatch]'s instrumentation, call key, and per-call options are + * all carried forward by [org.dexpace.sdk.core.http.context.DispatchContext.toRequestContext]. + * + * @return The promoted request context bound to the projected request. + * @throws IllegalArgumentException If a `{name}` placeholder has no matching path param. + */ + @JvmStatic + public fun projectInto( + dispatch: DispatchContext, + baseUrl: URL, + method: Method, + pathTemplate: String, + params: OperationParams, + ): RequestContext = dispatch.toRequestContext(project(baseUrl, method, pathTemplate, params)) + + private fun substitutePath( + pathTemplate: String, + pathParams: List, + ): String { + var result = pathTemplate + for (param in pathParams) { + val placeholder = "{" + param.name + "}" + require(result.contains(placeholder)) { + "path template '$pathTemplate' has no placeholder for path param '${param.name}'" + } + result = result.replace(placeholder, encodePathSegment(param.value)) + } + require(!hasUnresolvedPlaceholder(result)) { + "path template '$pathTemplate' has unresolved placeholders after substitution: '$result'" + } + return result + } + + /** + * Detects a leftover `{name}` placeholder. A bare `{` or `}` (legal in a URL) is not flagged; + * only a `{...}` pair with no slash between the braces counts as an unresolved placeholder. + */ + private fun hasUnresolvedPlaceholder(path: String): Boolean { + val open = path.indexOf('{') + if (open < 0) return false + val close = path.indexOf('}', open + 1) + if (close < 0) return false + return !path.substring(open + 1, close).contains('/') + } + + private fun encodeQuery(queryParams: List): String { + if (queryParams.isEmpty()) return "" + return queryParams.joinToString("&") { param -> + val name = encodeFormComponent(param.name) + when (val value = param.value) { + null -> name + else -> name + "=" + encodeFormComponent(value) + } + } + } + + /** + * Percent-encode a single path segment value. [encodeFormComponent] already escapes `/` + * (as `%2F`) so an embedded slash cannot introduce a new segment, and rewrites the + * form-encoder's `+`-for-space to `%20`, which is what a path segment requires. + */ + private fun encodePathSegment(value: String): String = encodeFormComponent(value) + + /** + * `application/x-www-form-urlencoded` encode, then rewrite `+` to `%20` so a literal space + * round-trips as a space rather than a plus. `URLEncoder` is the only JDK 8 primitive for this. + */ + private fun encodeFormComponent(raw: String): String = + URLEncoder + .encode(raw, StandardCharsets.UTF_8.name()) + .replace(SPACE_PLUS, SPACE_PCT) +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsPropagationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsPropagationTest.kt new file mode 100644 index 00000000..a367e1c1 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsPropagationTest.kt @@ -0,0 +1,80 @@ +/* + * 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.instrumentation.TraceId +import java.time.Duration +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertSame + +class CallOptionsPropagationTest { + private val ownedIds: MutableList = mutableListOf() + + @AfterTest + fun evict() { + for (id in ownedIds) ContextStore.remove(id) + } + + private fun owned(name: String): String = "options-$name-${System.nanoTime()}".also { ownedIds.add(it) } + + @Test + fun `dispatch context defaults callOptions to NONE`() { + val ctx = DispatchContext(FakeInstrumentationContext(TraceId(owned("default")))) + ownedIds.add(ctx.callKey) + assertSame(CallOptions.NONE, ctx.callOptions) + } + + @Test + fun `callOptions propagate unchanged through the promotion chain`() { + val options = + CallOptions.builder() + .timeout(CallTimeout.ofCall(Duration.ofSeconds(5))) + .responseValidation(ResponseValidation.DISABLED) + .build() + val dispatch = + DispatchContext( + instrumentationContext = FakeInstrumentationContext(TraceId(owned("propagate"))), + callOptions = options, + ) + ownedIds.add(dispatch.callKey) + + val requestCtx = dispatch.toRequestContext(request()) + val exchangeCtx = requestCtx.toExchangeContext(response()) + + assertSame(options, requestCtx.callOptions) + assertSame(options, exchangeCtx.callOptions) + } + + @Test + fun `request context carries callOptions when constructed off-chain`() { + val options = CallOptions.builder().responseValidation(ResponseValidation.ENABLED).build() + val key = owned("offchain") + val ctx = + RequestContext( + instrumentationContext = FakeInstrumentationContext(TraceId(key)), + request = request(), + callKey = key, + callOptions = options, + ) + assertSame(options, ctx.callOptions) + } + + @Test + fun `data class equality includes callOptions`() { + val key = owned("equality") + val instr = FakeInstrumentationContext(TraceId(key)) + val withOptions = + DispatchContext(instr, key, CallOptions.builder().responseValidation(ResponseValidation.DISABLED).build()) + val withNone = DispatchContext(instr, key, CallOptions.NONE) + assertEquals(withNone, DispatchContext(instr, key)) + // Distinct call options make otherwise-identical contexts unequal. + kotlin.test.assertNotEquals(withOptions, withNone) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsTest.kt new file mode 100644 index 00000000..28bc05a0 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsTest.kt @@ -0,0 +1,170 @@ +/* + * 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.KeyCredential +import java.time.Duration +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CallOptionsTest { + private companion object { + private val RETRYABLE: CallOption = CallOption("retryable") + private val LABEL: CallOption = CallOption("label") + } + + @Test + fun `NONE overrides nothing`() { + val none = CallOptions.NONE + assertTrue(none.isEmpty()) + assertEquals(CallTimeout.NONE, none.timeout) + assertEquals(ResponseValidation.INHERIT, none.responseValidation) + assertNull(none.credentialOverride) + assertTrue(none.attributes.isEmpty()) + } + + @Test + fun `builder sets the well-known fields`() { + val credential = KeyCredential("secret") + val options = + CallOptions.builder() + .timeout(CallTimeout.ofCall(Duration.ofSeconds(5))) + .responseValidation(ResponseValidation.DISABLED) + .credentialOverride(credential) + .build() + + assertEquals(Duration.ofSeconds(5), options.timeout.call) + assertEquals(ResponseValidation.DISABLED, options.responseValidation) + assertSame(credential, options.credentialOverride) + assertFalse(options.isEmpty()) + } + + @Test + fun `typed attributes round-trip with the key's value type`() { + val options = + CallOptions.builder() + .attribute(RETRYABLE, true) + .attribute(LABEL, "list-users") + .build() + + val retryable: Boolean? = options.get(RETRYABLE) + val label: String? = options.get(LABEL) + assertEquals(true, retryable) + assertEquals("list-users", label) + assertTrue(options.contains(RETRYABLE)) + assertEquals("fallback", options.getOrDefault(CallOption("absent"), "fallback")) + } + + @Test + fun `get returns null for an absent key`() { + assertNull(CallOptions.NONE.get(RETRYABLE)) + assertFalse(CallOptions.NONE.contains(RETRYABLE)) + } + + @Test + fun `distinct keys with the same name do not collide`() { + val keyA: CallOption = CallOption("dup") + val keyB: CallOption = CallOption("dup") + val options = CallOptions.builder().attribute(keyA, "a").attribute(keyB, "b").build() + assertEquals("a", options.get(keyA)) + assertEquals("b", options.get(keyB)) + } + + @Test + fun `removeAttribute drops a previously-set key`() { + val options = + CallOptions.builder() + .attribute(LABEL, "x") + .removeAttribute(LABEL) + .build() + assertNull(options.get(LABEL)) + } + + @Test + fun `built attributes are an immutable snapshot`() { + val builder = CallOptions.builder().attribute(LABEL, "first") + val built = builder.build() + // Mutating the builder after build does not leak into the built instance. + builder.attribute(LABEL, "second") + assertEquals("first", built.get(LABEL)) + } + + @Test + fun `newBuilder round-trips every field`() { + val original = + CallOptions.builder() + .timeout(CallTimeout.ofCall(Duration.ofSeconds(2))) + .responseValidation(ResponseValidation.ENABLED) + .credentialOverride(KeyCredential("k")) + .attribute(LABEL, "op") + .build() + assertEquals(original, original.newBuilder().build()) + } + + @Test + fun `applyDefaults merges each field independently`() { + val callCredential = KeyCredential("call") + val receiver = + CallOptions.builder() + .timeout(CallTimeout.builder().connect(Duration.ofSeconds(1)).build()) + .credentialOverride(callCredential) + .attribute(LABEL, "call-label") + .build() + val defaults = + CallOptions.builder() + .timeout(CallTimeout.builder().read(Duration.ofSeconds(9)).build()) + .responseValidation(ResponseValidation.ENABLED) + .credentialOverride(KeyCredential("client")) + .attribute(RETRYABLE, true) + .build() + + val merged = receiver.applyDefaults(defaults) + + // Timeout merges per phase: receiver connect wins, default read fills in. + assertEquals(Duration.ofSeconds(1), merged.timeout.connect) + assertEquals(Duration.ofSeconds(9), merged.timeout.read) + // Response validation: receiver is INHERIT, so the default's ENABLED shows through. + assertEquals(ResponseValidation.ENABLED, merged.responseValidation) + // Credential: receiver's wins. + assertSame(callCredential, merged.credentialOverride) + // Attributes union; receiver wins on collisions, default fills the rest. + assertEquals("call-label", merged.get(LABEL)) + assertEquals(true, merged.get(RETRYABLE)) + } + + @Test + fun `applyDefaults attribute collision is won by the receiver`() { + val receiver = CallOptions.builder().attribute(LABEL, "call").build() + val defaults = CallOptions.builder().attribute(LABEL, "client").build() + assertEquals("call", receiver.applyDefaults(defaults).get(LABEL)) + } + + @Test + fun `NONE is the identity element of applyDefaults`() { + val x = + CallOptions.builder() + .timeout(CallTimeout.ofCall(Duration.ofSeconds(3))) + .responseValidation(ResponseValidation.DISABLED) + .attribute(LABEL, "op") + .build() + assertSame(x, x.applyDefaults(CallOptions.NONE)) + assertEquals(x, CallOptions.NONE.applyDefaults(x)) + } + + @Test + fun `credential override falls through to defaults when absent`() { + val clientCredential = KeyCredential("client") + val receiver = CallOptions.NONE + val defaults = CallOptions.builder().credentialOverride(clientCredential).build() + assertSame(clientCredential, receiver.applyDefaults(defaults).credentialOverride) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallTimeoutTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallTimeoutTest.kt new file mode 100644 index 00000000..4aa9d44d --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallTimeoutTest.kt @@ -0,0 +1,129 @@ +/* + * 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 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class CallTimeoutTest { + @Test + fun `NONE inherits every phase`() { + val none = CallTimeout.NONE + assertTrue(none.isEmpty()) + assertNull(none.connect) + assertNull(none.write) + assertNull(none.read) + assertNull(none.call) + } + + @Test + fun `builder sets individual phases`() { + val timeout = + CallTimeout.builder() + .connect(Duration.ofSeconds(1)) + .write(Duration.ofSeconds(2)) + .read(Duration.ofSeconds(3)) + .call(Duration.ofSeconds(4)) + .build() + + assertEquals(Duration.ofSeconds(1), timeout.connect) + assertEquals(Duration.ofSeconds(2), timeout.write) + assertEquals(Duration.ofSeconds(3), timeout.read) + assertEquals(Duration.ofSeconds(4), timeout.call) + assertFalse(timeout.isEmpty()) + } + + @Test + fun `ofCall sets only the call phase`() { + val timeout = CallTimeout.ofCall(Duration.ofSeconds(5)) + assertEquals(Duration.ofSeconds(5), timeout.call) + assertNull(timeout.connect) + assertNull(timeout.read) + assertNull(timeout.write) + } + + @Test + fun `newBuilder round-trips`() { + val original = CallTimeout.builder().read(Duration.ofSeconds(7)).build() + val rebuilt = original.newBuilder().build() + assertEquals(original, rebuilt) + } + + @Test + fun `applyDefaults takes receiver value when set`() { + val receiver = CallTimeout.builder().connect(Duration.ofSeconds(1)).build() + val defaults = CallTimeout.builder().connect(Duration.ofSeconds(9)).read(Duration.ofSeconds(2)).build() + + val merged = receiver.applyDefaults(defaults) + + // Receiver's connect wins; read falls through from defaults. + assertEquals(Duration.ofSeconds(1), merged.connect) + assertEquals(Duration.ofSeconds(2), merged.read) + } + + @Test + fun `applyDefaults coalesces unset phases from defaults`() { + val receiver = CallTimeout.NONE + val defaults = + CallTimeout.builder() + .connect(Duration.ofSeconds(1)) + .call(Duration.ofSeconds(8)) + .build() + + val merged = receiver.applyDefaults(defaults) + assertEquals(defaults, merged) + } + + @Test + fun `applyDefaults returns this when nothing changes`() { + val full = + CallTimeout.builder() + .connect(Duration.ofSeconds(1)) + .write(Duration.ofSeconds(2)) + .read(Duration.ofSeconds(3)) + .call(Duration.ofSeconds(4)) + .build() + + // A fully-specified overlay ignores any defaults; the merge is identity. + assertSame(full, full.applyDefaults(CallTimeout.ofCall(Duration.ofSeconds(99)))) + assertSame(full, full.applyDefaults(CallTimeout.NONE)) + } + + @Test + fun `applyDefaults is associative as a first-non-null overlay`() { + val a = CallTimeout.builder().connect(Duration.ofSeconds(1)).build() + val b = CallTimeout.builder().connect(Duration.ofSeconds(2)).read(Duration.ofSeconds(2)).build() + val c = CallTimeout.builder().read(Duration.ofSeconds(3)).call(Duration.ofSeconds(3)).build() + + val merged = a.applyDefaults(b).applyDefaults(c) + + assertEquals(Duration.ofSeconds(1), merged.connect) + assertEquals(Duration.ofSeconds(2), merged.read) + assertEquals(Duration.ofSeconds(3), merged.call) + assertNull(merged.write) + } + + @Test + fun `negative phase is rejected`() { + assertFailsWith { + CallTimeout.builder().read(Duration.ofSeconds(-1)).build() + } + } + + @Test + fun `zero phase is allowed`() { + val timeout = CallTimeout.builder().call(Duration.ZERO).build() + assertEquals(Duration.ZERO, timeout.call) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/ResponseValidationTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/ResponseValidationTest.kt new file mode 100644 index 00000000..fbb07170 --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/ResponseValidationTest.kt @@ -0,0 +1,27 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals + +class ResponseValidationTest { + @Test + fun `INHERIT coalesces to the default`() { + assertEquals(ResponseValidation.ENABLED, ResponseValidation.INHERIT.applyDefault(ResponseValidation.ENABLED)) + assertEquals(ResponseValidation.DISABLED, ResponseValidation.INHERIT.applyDefault(ResponseValidation.DISABLED)) + assertEquals(ResponseValidation.INHERIT, ResponseValidation.INHERIT.applyDefault(ResponseValidation.INHERIT)) + } + + @Test + fun `explicit choice overrides the default`() { + assertEquals(ResponseValidation.ENABLED, ResponseValidation.ENABLED.applyDefault(ResponseValidation.DISABLED)) + assertEquals(ResponseValidation.DISABLED, ResponseValidation.DISABLED.applyDefault(ResponseValidation.ENABLED)) + assertEquals(ResponseValidation.ENABLED, ResponseValidation.ENABLED.applyDefault(ResponseValidation.INHERIT)) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/OperationParamsTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/OperationParamsTest.kt new file mode 100644 index 00000000..97c55e3e --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/OperationParamsTest.kt @@ -0,0 +1,70 @@ +/* + * 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.operation + +import org.dexpace.sdk.core.http.common.Headers +import org.dexpace.sdk.core.http.common.MediaType +import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.io.BufferedSink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** A bodyless probe body that needs no installed IO provider — never written in these tests. */ +private class ParamsStubBody : RequestBody() { + override fun mediaType(): MediaType? = null + + override fun writeTo(sink: BufferedSink): Unit = throw UnsupportedOperationException("not written in tests") +} + +class OperationParamsTest { + @Test + fun `NONE projects nothing`() { + val none = OperationParams.NONE + assertTrue(none.pathParams().isEmpty()) + assertTrue(none.queryParams().isEmpty()) + assertEquals(OperationParams.EMPTY_HEADERS, none.headers()) + assertNull(none.body()) + } + + @Test + fun `default methods only require overriding the contributed parts`() { + val queryOnly = + object : OperationParams { + override fun queryParams(): List = listOf(QueryParam("limit", "10")) + } + assertEquals(listOf(QueryParam("limit", "10")), queryOnly.queryParams()) + // Untouched projections keep their empty defaults. + assertTrue(queryOnly.pathParams().isEmpty()) + assertNull(queryOnly.body()) + assertEquals(OperationParams.EMPTY_HEADERS, queryOnly.headers()) + } + + @Test + fun `a fully-populated projection exposes every part`() { + val body = ParamsStubBody() + val headers = Headers.Builder().add("X-Trace", "abc").build() + val params = + object : OperationParams { + override fun pathParams(): List = listOf(PathParam("id", "42")) + + override fun queryParams(): List = listOf(QueryParam("expand", "all")) + + override fun headers(): Headers = headers + + override fun body(): RequestBody = body + } + + assertEquals(listOf(PathParam("id", "42")), params.pathParams()) + assertEquals(listOf(QueryParam("expand", "all")), params.queryParams()) + assertSame(headers, params.headers()) + assertSame(body, params.body()) + } +} diff --git a/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/RequestProjectorTest.kt b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/RequestProjectorTest.kt new file mode 100644 index 00000000..8210542c --- /dev/null +++ b/sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/RequestProjectorTest.kt @@ -0,0 +1,153 @@ +/* + * 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.operation + +import org.dexpace.sdk.core.http.common.Headers +import org.dexpace.sdk.core.http.common.MediaType +import org.dexpace.sdk.core.http.context.CallOptions +import org.dexpace.sdk.core.http.context.CallTimeout +import org.dexpace.sdk.core.http.context.ContextStore +import org.dexpace.sdk.core.http.context.DispatchContext +import org.dexpace.sdk.core.http.context.ResponseValidation +import org.dexpace.sdk.core.http.request.Method +import org.dexpace.sdk.core.http.request.RequestBody +import org.dexpace.sdk.core.io.BufferedSink +import java.net.URL +import java.time.Duration +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +private class ProjectorStubBody : RequestBody() { + override fun mediaType(): MediaType? = null + + override fun writeTo(sink: BufferedSink): Unit = throw UnsupportedOperationException("not written in tests") +} + +class RequestProjectorTest { + private val ownedKeys: MutableList = mutableListOf() + + @AfterTest + fun evict() { + for (key in ownedKeys) ContextStore.remove(key) + } + + private val base = URL("https://api.example.test/v1/") + + @Test + fun `substitutes a path placeholder`() { + val params = + object : OperationParams { + override fun pathParams(): List = listOf(PathParam("id", "42")) + } + val request = RequestProjector.project(base, Method.GET, "users/{id}", params) + assertEquals("https://api.example.test/v1/users/42", request.url.toExternalForm()) + assertEquals(Method.GET, request.method) + } + + @Test + fun `percent-encodes a path value including slashes`() { + val params = + object : OperationParams { + override fun pathParams(): List = listOf(PathParam("name", "a/b c")) + } + val request = RequestProjector.project(base, Method.GET, "items/{name}", params) + assertEquals("https://api.example.test/v1/items/a%2Fb%20c", request.url.toExternalForm()) + } + + @Test + fun `appends an ordered query string with repetition`() { + val params = + object : OperationParams { + override fun queryParams(): List = + listOf( + QueryParam("tag", "a"), + QueryParam("tag", "b"), + QueryParam("limit", "10"), + ) + } + val request = RequestProjector.project(base, Method.GET, "search", params) + assertEquals("https://api.example.test/v1/search?tag=a&tag=b&limit=10", request.url.toExternalForm()) + } + + @Test + fun `encodes query name and value and renders a valueless param`() { + val params = + object : OperationParams { + override fun queryParams(): List = + listOf( + QueryParam("q", "hello world&x"), + QueryParam("flag", null), + ) + } + val request = RequestProjector.project(base, Method.GET, "search", params) + assertEquals("https://api.example.test/v1/search?q=hello%20world%26x&flag", request.url.toExternalForm()) + } + + @Test + fun `applies headers and body`() { + val body = ProjectorStubBody() + val headers = Headers.Builder().add("X-Trace", "abc").build() + val params = + object : OperationParams { + override fun headers(): Headers = headers + + override fun body(): RequestBody = body + } + val request = RequestProjector.project(base, Method.POST, "users", params) + assertEquals("abc", request.headers.get("X-Trace")) + assertSame(body, request.body) + assertEquals(Method.POST, request.method) + } + + @Test + fun `missing path param fails`() { + assertFailsWith { + RequestProjector.project(base, Method.GET, "users/{id}", OperationParams.NONE) + } + } + + @Test + fun `extra path param without a placeholder fails`() { + val params = + object : OperationParams { + override fun pathParams(): List = listOf(PathParam("missing", "x")) + } + assertFailsWith { + RequestProjector.project(base, Method.GET, "users", params) + } + } + + @Test + fun `projectInto promotes a dispatch context and carries its call options forward`() { + val options = + CallOptions.builder() + .timeout(CallTimeout.ofCall(Duration.ofSeconds(5))) + .responseValidation(ResponseValidation.DISABLED) + .build() + val dispatch = + DispatchContext( + instrumentationContext = DispatchContext.default().instrumentationContext, + callOptions = options, + ) + ownedKeys.add(dispatch.callKey) + + val params = + object : OperationParams { + override fun pathParams(): List = listOf(PathParam("id", "7")) + } + val requestCtx = RequestProjector.projectInto(dispatch, base, Method.GET, "users/{id}", params) + + assertEquals("https://api.example.test/v1/users/7", requestCtx.request.url.toExternalForm()) + assertSame(options, requestCtx.callOptions) + assertEquals(dispatch.callKey, requestCtx.callKey) + assertSame(requestCtx, ContextStore.get(requestCtx.callKey)) + } +}