From d8ad46b8e60e9f5fcd80155f2748cd586090a2c0 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Wed, 17 Jun 2026 05:55:12 +0300 Subject: [PATCH] feat: add per-call options channel and OperationParams projection SPI Introduce two sdk-core runtime primitives that let a single call override client behaviour and let an operation describe its inputs, without mutating the shared client or generating code. Per-call options: - CallOptions: an immutable, per-call options bag carrying a timeout overlay, a response-validation decision, an optional ad-hoc credential, and an open set of typed extension attributes keyed by CallOption. applyDefaults merges a call-site set onto a client baseline with per-field precedence (receiver wins, falling back to defaults); CallOptions.NONE is the identity. - CallTimeout: a per-phase (connect/write/read/call) timeout overlay where a null phase means "inherit", with the same applyDefaults overlay semantics. - ResponseValidation: a three-state enum (INHERIT/ENABLED/DISABLED) so the inherit case is a first-class non-null value rather than a nullable boolean. - CallContext now exposes callOptions; DispatchContext mints it once at the head of the chain and every promotion carries it forward unchanged, so the dispatch / request / exchange phases all observe the same overrides. It defaults to CallOptions.NONE, preserving existing construction sites. Operation projection: - OperationParams: a narrow SPI projecting an operation's inputs into path, query, headers, and body, each with an empty default so an implementation overrides only what it contributes. PathParam and QueryParam are the raw, unencoded value types it returns. - RequestProjector: materializes an OperationParams plus a method and path template into a Request (owning percent-encoding for path segments and the query string), and projectInto promotes a DispatchContext straight into a RequestContext, tying the SPI to the context chain in one call. Public API surface regenerated via apiDump. --- sdk-core/api/sdk-core.api | 177 ++++++++++++++-- .../sdk/core/http/context/CallContext.kt | 9 + .../sdk/core/http/context/CallOption.kt | 32 +++ .../sdk/core/http/context/CallOptions.kt | 189 ++++++++++++++++++ .../sdk/core/http/context/CallTimeout.kt | 150 ++++++++++++++ .../sdk/core/http/context/DispatchContext.kt | 9 +- .../sdk/core/http/context/ExchangeContext.kt | 1 + .../sdk/core/http/context/RequestContext.kt | 9 +- .../core/http/context/ResponseValidation.kt | 35 ++++ .../sdk/core/operation/OperationParams.kt | 79 ++++++++ .../dexpace/sdk/core/operation/PathParam.kt | 25 +++ .../dexpace/sdk/core/operation/QueryParam.kt | 30 +++ .../sdk/core/operation/RequestProjector.kt | 151 ++++++++++++++ .../context/CallOptionsPropagationTest.kt | 80 ++++++++ .../sdk/core/http/context/CallOptionsTest.kt | 170 ++++++++++++++++ .../sdk/core/http/context/CallTimeoutTest.kt | 129 ++++++++++++ .../http/context/ResponseValidationTest.kt | 27 +++ .../sdk/core/operation/OperationParamsTest.kt | 70 +++++++ .../core/operation/RequestProjectorTest.kt | 153 ++++++++++++++ 19 files changed, 1507 insertions(+), 18 deletions(-) create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOption.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallOptions.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/CallTimeout.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/context/ResponseValidation.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/OperationParams.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/PathParam.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/QueryParam.kt create mode 100644 sdk-core/src/main/kotlin/org/dexpace/sdk/core/operation/RequestProjector.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsPropagationTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallOptionsTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/CallTimeoutTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/http/context/ResponseValidationTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/OperationParamsTest.kt create mode 100644 sdk-core/src/test/kotlin/org/dexpace/sdk/core/operation/RequestProjectorTest.kt 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)) + } +}