Skip to content

Commit 2f7d288

Browse files
committed
feat: add QueryParams multimap and OperationParams input-projection SPI
Introduce a structured query-string model and the operation-input projection seam, replacing the placeholder query type and the split('&') string surgery in pagination. - QueryParams (http.common): immutable, insertion-ordered, multi-valued query model with RFC 3986 encoding (space -> %20, literal + -> %2B) and an order-sensitive equals that agrees with encode(). It is an origination model for building queries, not a fidelity-preserving URL editor. - PercentEncoding (http.common): shared RFC 3986 URL-component codec used for both query components and path segments (/ -> %2F, so a path value cannot inject extra segments). - OperationParams (operation): SPI that projects an operation's typed inputs (path / query / header / body) into a Request and the context chain, via toRequest(baseUrl) and toRequestContext(baseUrl, dispatch). Path templates (/pets/{id}) are substituted with path-segment encoding; a missing variable fails fast. Execution stays the pipeline's job. - RequestRebuilder: query edits now splice the raw query string, preserving untouched parameters byte-for-byte (a value-less ?flag stays value-less, reserved characters are not rewritten) and encoding only the targeted parameter; reads go through QueryParams. - Remove the dead QueryParam placeholder stub and its test. - Record the request-URL model decision (keep java.net.URL, layer QueryParams) and document the new types in docs/http.md and the README package map. Closes #28 Closes #29 Closes #57
1 parent dba4cc2 commit 2f7d288

14 files changed

Lines changed: 1342 additions & 99 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,14 +265,15 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
265265
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
266266
| `http.response` | `Response`, `ResponseBody`, `LoggableResponseBody`, `Status` (a value-carrying class with a total `fromCode`), plus the raw-vs-parsed seam: `ResponseHandler<T>` (with dep-free `string()`/`empty()` handlers) and a lazy, parse-once `ParsedResponse<T>`. |
267267
| `http.response.exception` | Typed `HttpException` hierarchy (`BadRequestException`, `RequestTimeoutException`, `TooManyRequestsException`, `ServiceUnavailableException`, …) with `isRetryable` derived from `RetryUtils.isRetryable` and exposed via the `Retryable` interface, plus `NetworkException` and `HttpExceptionFactory`. |
268-
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
268+
| `http.common` | `Headers`, `HttpHeaderName` (interned), `QueryParams` (RFC 3986 query multimap), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
269269
| `http.context` | `CallContext``DispatchContext``RequestContext``ExchangeContext` chain, `ContextStore`. |
270270
| `http.pipeline` | Sync (`HttpStep` / `HttpPipeline` / `HttpPipelineBuilder` / `PipelineNext` / `Stage`) and async (`AsyncHttpStep` / `AsyncHttpPipeline` / `AsyncHttpPipelineBuilder` / `AsyncPipelineNext`) pipeline machinery, plus `AsyncPipelineBridges`. |
271271
| `http.pipeline.steps` | Concrete steps: `RetryStep`, `RedirectStep`, `AuthStep`, `KeyCredentialAuthStep`, `BearerTokenAuthStep`, `InstrumentationStep`, `SetDateStep`, and their `*Options` / `*Condition` types. |
272272
| `http.sse` | `ServerSentEventReader` (WHATWG spec), `ServerSentEvent`, `ServerSentEventListener`, `BufferedSource.readServerSentEvents()`. |
273273
| `http.paging` | `PagedIterable<T>`, `PagedResponse<T>`, `PagingOptions` with `byPage()` and `stream()` accessors. |
274274
| `auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, per-operation `AuthRequirement` / `AuthDescriptor` with `AuthDescriptorResolver` precedence ladder, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. |
275275
| `pagination` | `Paginator<T>` (with a `maxPages` safety cap) over cursor / page-number / link-header `PaginationStrategy` implementations, plus `Page<T>` / `SimplePage<T>`. Token-style APIs use `CursorPaginationStrategy` with the query-param name set (e.g. `"page_token"`). |
276+
| `operation` | `OperationParams` — SPI projecting an operation's typed inputs (path / query / header / body) into a `Request` and the context chain, via `toRequest(baseUrl)` / `toRequestContext(baseUrl, dispatch)`. |
276277
| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
277278
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate<T>` (absent / null / present), and `SerdeException` (the unchecked failure adapters translate codec errors into). |
278279
| `io` | `Source`, `Sink`, `Buffer`, `BufferedSource`, `BufferedSink`, `IoProvider`, `Io`, `TeeSink`. |

docs/http.md

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,67 @@ headers.get("content-type") // "application/json" (case-insensitive)
438438
headers.values("Cache-Control") // ["no-cache", "no-store"]
439439
```
440440

441+
### QueryParams
442+
443+
`QueryParams` is an immutable, insertion-ordered, multi-valued model of a URL query string —
444+
the `?name=value&...` portion of a URL. It mirrors `Headers` in shape (private constructor,
445+
mutable `Builder`, multi-value semantics) but differs in three ways: names are
446+
**case-sensitive** (`?page=1` and `?Page=1` are distinct), values may be **empty or value-less**
447+
(`?flag` and `?flag=` both occur in the wild), and equality is **order-sensitive** — two
448+
instances are equal only if they `encode()` identically. (That last point is the one divergence
449+
from `Headers`, whose case-folded names make name order non-semantic; here order is a rendered
450+
property, so it counts.)
451+
452+
```kotlin
453+
class QueryParams private constructor(
454+
private val paramsMap: Map<String, List<String>>
455+
)
456+
```
457+
458+
**Role — building queries, not editing URLs.** `QueryParams` is an *origination* model: it
459+
builds a query string from decoded names/values (for example, projecting an operation's inputs
460+
into a request). It is **not** a fidelity-preserving editor of an existing URL. `encode()`
461+
re-renders every parameter in canonical form, so round-tripping an arbitrary URL through `parse`
462+
then `encode` can change the wire form of parameters you never touched (`?flag``flag=`,
463+
reserved characters percent-encoded). Code that must edit one parameter of an existing URL while
464+
leaving the rest byte-for-byte — pagination's `RequestRebuilder` — splices the raw query string
465+
directly instead of going through `encode()`.
466+
467+
**API:**
468+
469+
| Method | Description |
470+
|------------------|----------------------------------------------------------------------------|
471+
| `get(name)` | First value for the name, or `null` if absent (`""` for a value-less param)|
472+
| `values(name)` | All values for the name (unmodifiable), or empty list |
473+
| `contains(name)` | Whether any value is present for the name |
474+
| `names()` | Immutable, insertion-ordered snapshot of all parameter names |
475+
| `entries()` | Immutable snapshot as `Map.Entry<String, List<String>>` |
476+
| `size()` | Total number of values across all names (derived, not tracked) |
477+
| `isEmpty()` | Whether there are no parameters |
478+
| `encode()` | RFC 3986 query string (space → `%20`, literal `+``%2B`), no leading `?` |
479+
| `newBuilder()` | Returns a pre-filled `Builder` for modification |
480+
481+
**Encoding.** `encode()` / `parse()` use **RFC 3986 query semantics** (via the internal
482+
`PercentEncoding` helper): a space is `%20` (not `+`), and a literal `+` is `%2B` — it is **not**
483+
read back as a space. This is deliberately *not* `application/x-www-form-urlencoded`: a query
484+
*assembled as a request body* uses the form scheme (`+` for spaces) and will be a separate
485+
form-body type, not `QueryParams.encode()`. `parse(encode(...))` round-trips names, values, and
486+
order; malformed percent-encoding falls back to raw text rather than throwing.
487+
488+
**Builder:**
489+
490+
```kotlin
491+
val params = QueryParams.builder()
492+
.add("tag", "a")
493+
.add("tag", "b") // multi-value
494+
.set("page", "2") // replaces any existing "page"
495+
.build()
496+
497+
params.values("tag") // ["a", "b"]
498+
params.get("page") // "2"
499+
params.encode() // "tag=a&tag=b&page=2"
500+
```
501+
441502
### MediaType
442503

443504
`MediaType` represents a parsed MIME type with optional parameters:
@@ -685,6 +746,62 @@ Both implement `HttpClient` and `AsyncHttpClient` on a single class. See the REA
685746

686747
---
687748

749+
## Operation Input Projection
750+
751+
`OperationParams` (`org.dexpace.sdk.core.operation`) is the SPI a thin generated service implements
752+
once per operation to declare where each typed input belongs on the wire — **path**, **query**,
753+
**header**, or **body** — so generated code (and typed pagination) never splices a URL string. The
754+
runtime assembles the `Request` and feeds it into the context chain.
755+
756+
```kotlin
757+
interface OperationParams {
758+
val method: Method
759+
val pathTemplate: String // "/pets/{petId}"; leading "/" optional
760+
val operationName: String? // for the tracing seam; default null
761+
762+
fun pathParams(): Map<String, String> // default emptyMap()
763+
fun queryParams(): QueryParams // default empty
764+
fun headers(): Headers // default empty
765+
fun body(): RequestBody? // default null
766+
767+
fun toRequest(baseUrl: String): Request
768+
fun toRequestContext(baseUrl: String, dispatch: DispatchContext): RequestContext
769+
}
770+
```
771+
772+
Only `method` and `pathTemplate` are required; the four projections default to empty, so a
773+
parameterless operation overrides almost nothing.
774+
775+
**Assembly** (`toRequest`):
776+
777+
- **Path** — each `{name}` in `pathTemplate` is replaced with its `pathParams()` value,
778+
percent-encoded as a path segment (`/``%2F`), so a value cannot inject extra segments. A
779+
`{name}` with no value throws `IllegalArgumentException`.
780+
- **Query**`queryParams().encode()` (RFC 3986) is appended after `?`.
781+
- **Base URL** — treated as a verbatim prefix; a trailing `/` is trimmed and exactly one `/` joins
782+
it to the resolved path, so `https://api.example.com/v1` + `/pets``…/v1/pets`.
783+
- **Headers / body / method** — set verbatim from the projections; `Request.build()` validates
784+
body/method compatibility.
785+
786+
`toRequestContext` builds the `Request` and promotes a `DispatchContext` into a `RequestContext`
787+
carrying it, in one step. Execution stays the pipeline's job — the SPI stops at producing the
788+
request/context (error-mapping and deserialization compose at the service layer, not as pipeline
789+
stages).
790+
791+
```kotlin
792+
class ListPets(private val limit: Int?) : OperationParams {
793+
override val method = Method.GET
794+
override val pathTemplate = "/pets"
795+
override fun queryParams() =
796+
QueryParams.builder().apply { limit?.let { set("limit", it.toString()) } }.build()
797+
}
798+
799+
val request = ListPets(limit = 20).toRequest("https://api.example.com") // GET …/pets?limit=20
800+
val response = httpClient.execute(request)
801+
```
802+
803+
---
804+
688805
## Design Decisions
689806

690807
### Bodies Over the SDK's I/O Abstraction
@@ -749,6 +866,36 @@ Specific API choices driven by JDK 8 targeting:
749866
| `java.net.http.HttpClient` (Java 11+) | `HttpClient` interface (transport-agnostic) |
750867
| `HttpHeaders` (Java 11+) | Custom `Headers` class |
751868

869+
### Request URL Model
870+
871+
`Request` stores its target as a single resolved `java.net.URL` (a string-backed container),
872+
**not** a fully deconstructed URL value object (scheme / host / port / path-segments / query).
873+
Structured query manipulation is layered on top via the `QueryParams` multimap.
874+
875+
**Decision: keep `java.net.URL` as the URL container; layer `QueryParams` for query
876+
manipulation.**
877+
878+
- **DNS-free equality is preserved.** `Request` equality compares `url.toExternalForm()` — a
879+
pure string comparison with no network I/O — because `java.net.URL.equals` / `hashCode`
880+
resolve the host via DNS (blocking, and wrong for virtual hosts sharing an address). Keeping
881+
the resolved-URL container carries that contract over unchanged.
882+
- **The query is where the manipulation pressure is.** Pagination and (later) operation-input
883+
projection manipulate the query, not the host or path. `QueryParams` puts a structured,
884+
multi-valued, well-tested model exactly there, without forcing a rewrite of how transports
885+
consume a URL.
886+
- **Transports already speak `java.net.URL` / strings.** Both reference transports accept a
887+
resolved URL or string directly; a deconstructed model would add an assembly step at every
888+
transport boundary for no functional gain today.
889+
890+
Path-template *substitution* (`/pets/{id}` + values → an encoded path) lands minimally with the
891+
`OperationParams` SPI — see "Operation Input Projection" above. What remains **deferred** is a
892+
*structured* URL model: a deconstructed `Url` value object and/or a move from `java.net.URL` to
893+
`java.net.URI`. `URI` gives DNS-free equality natively (no `toExternalForm()` workaround) and
894+
exposes the raw query and path, but parses more strictly and touches every transport boundary. The
895+
container choice (`URL` vs `URI` vs deconstructed) is best decided when richer path handling
896+
(per-segment typing, matrix params) actually earns it; the minimal template substitution above does
897+
not require it.
898+
752899
---
753900

754901
## Usage Examples
@@ -860,6 +1007,10 @@ exchangeCtx.close()
8601007
| `NetworkException.kt` | `http.response.exception`| public | Transport-level failure (IOException sibling)|
8611008
| `HttpExceptionFactory.kt` | `http.response.exception`| public | `Response` → typed exception dispatcher |
8621009
| `Headers.kt` | `http.common` | public | Immutable multi-map + builder |
1010+
| `QueryParams.kt` | `http.common` | public | Immutable query-string multi-map + builder |
1011+
| `PercentEncoding.kt` | `http.common` | internal | RFC 3986 URL-component percent-encoding (query + path) |
1012+
| `OperationParams.kt` | `operation` | public | SPI: project operation inputs → `Request` + context |
1013+
| `OperationRequestAssembler.kt` | `operation` | internal | Assembles a `Request` from an `OperationParams` |
8631014
| `MediaType.kt` | `http.common` | public | Parsed MIME type with charset extraction |
8641015
| `CommonMediaTypes.kt` | `http.common` | public | Media type constants |
8651016
| `Protocol.kt` | `http.common` | public | HTTP protocol version enum |

sdk-core/api/sdk-core.api

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,44 @@ public final class org/dexpace/sdk/core/http/common/Protocol$Companion {
580580
public final fun get (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/Protocol;
581581
}
582582

583+
public final class org/dexpace/sdk/core/http/common/QueryParams {
584+
public static final field Companion Lorg/dexpace/sdk/core/http/common/QueryParams$Companion;
585+
public synthetic fun <init> (Ljava/util/Map;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
586+
public static final fun builder ()Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
587+
public final fun contains (Ljava/lang/String;)Z
588+
public static final fun empty ()Lorg/dexpace/sdk/core/http/common/QueryParams;
589+
public final fun encode ()Ljava/lang/String;
590+
public final fun entries ()Ljava/util/Set;
591+
public fun equals (Ljava/lang/Object;)Z
592+
public final fun get (Ljava/lang/String;)Ljava/lang/String;
593+
public fun hashCode ()I
594+
public final fun isEmpty ()Z
595+
public final fun names ()Ljava/util/Set;
596+
public final fun newBuilder ()Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
597+
public static final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/QueryParams;
598+
public final fun size ()I
599+
public fun toString ()Ljava/lang/String;
600+
public final fun values (Ljava/lang/String;)Ljava/util/List;
601+
}
602+
603+
public final class org/dexpace/sdk/core/http/common/QueryParams$Builder {
604+
public fun <init> ()V
605+
public fun <init> (Lorg/dexpace/sdk/core/http/common/QueryParams;)V
606+
public final fun add (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
607+
public final fun add (Ljava/lang/String;Ljava/util/List;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
608+
public final fun addAll (Lorg/dexpace/sdk/core/http/common/QueryParams;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
609+
public final fun build ()Lorg/dexpace/sdk/core/http/common/QueryParams;
610+
public final fun remove (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
611+
public final fun set (Ljava/lang/String;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
612+
public final fun set (Ljava/lang/String;Ljava/util/List;)Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
613+
}
614+
615+
public final class org/dexpace/sdk/core/http/common/QueryParams$Companion {
616+
public final fun builder ()Lorg/dexpace/sdk/core/http/common/QueryParams$Builder;
617+
public final fun empty ()Lorg/dexpace/sdk/core/http/common/QueryParams;
618+
public final fun parse (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/common/QueryParams;
619+
}
620+
583621
public final class org/dexpace/sdk/core/http/common/RequestConditions {
584622
public static final field Companion Lorg/dexpace/sdk/core/http/common/RequestConditions$Companion;
585623
public synthetic fun <init> (Ljava/util/List;Ljava/util/List;Ljava/time/Instant;Ljava/time/Instant;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
@@ -2234,6 +2272,28 @@ public abstract interface class org/dexpace/sdk/core/io/Source : java/io/Closeab
22342272
public abstract fun read (Lorg/dexpace/sdk/core/io/Buffer;J)J
22352273
}
22362274

2275+
public abstract interface class org/dexpace/sdk/core/operation/OperationParams {
2276+
public fun body ()Lorg/dexpace/sdk/core/http/request/RequestBody;
2277+
public abstract fun getMethod ()Lorg/dexpace/sdk/core/http/request/Method;
2278+
public fun getOperationName ()Ljava/lang/String;
2279+
public abstract fun getPathTemplate ()Ljava/lang/String;
2280+
public fun headers ()Lorg/dexpace/sdk/core/http/common/Headers;
2281+
public fun pathParams ()Ljava/util/Map;
2282+
public fun queryParams ()Lorg/dexpace/sdk/core/http/common/QueryParams;
2283+
public fun toRequest (Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
2284+
public fun toRequestContext (Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/context/RequestContext;
2285+
}
2286+
2287+
public final class org/dexpace/sdk/core/operation/OperationParams$DefaultImpls {
2288+
public static fun body (Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/request/RequestBody;
2289+
public static fun getOperationName (Lorg/dexpace/sdk/core/operation/OperationParams;)Ljava/lang/String;
2290+
public static fun headers (Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/common/Headers;
2291+
public static fun pathParams (Lorg/dexpace/sdk/core/operation/OperationParams;)Ljava/util/Map;
2292+
public static fun queryParams (Lorg/dexpace/sdk/core/operation/OperationParams;)Lorg/dexpace/sdk/core/http/common/QueryParams;
2293+
public static fun toRequest (Lorg/dexpace/sdk/core/operation/OperationParams;Ljava/lang/String;)Lorg/dexpace/sdk/core/http/request/Request;
2294+
public static fun toRequestContext (Lorg/dexpace/sdk/core/operation/OperationParams;Ljava/lang/String;Lorg/dexpace/sdk/core/http/context/DispatchContext;)Lorg/dexpace/sdk/core/http/context/RequestContext;
2295+
}
2296+
22372297
public final class org/dexpace/sdk/core/pagination/AsyncPaginator {
22382298
public fun <init> (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;)V
22392299
public fun <init> (Lorg/dexpace/sdk/core/client/AsyncHttpClient;Lorg/dexpace/sdk/core/http/request/Request;Lorg/dexpace/sdk/core/pagination/PaginationStrategy;J)V

sdk-core/src/main/kotlin/org/dexpace/sdk/core/http/QueryParam.kt

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)