@@ -438,6 +438,67 @@ headers.get("content-type") // "application/json" (case-insensitive)
438438headers.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,70 @@ 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** — the scheme/host/port/base-path carry through unchanged; a trailing ` / ` is trimmed
782+ and exactly one ` / ` joins it to the resolved path, so ` https://api.example.com/v1 ` + ` /pets ` →
783+ ` …/v1/pets ` . A query already on the base URL is preserved: the resolved path is inserted ** before**
784+ it and the operation's query is appended after it, so a signed base
785+ ` https://host/c?sig=… ` + ` /pets?limit=20 ` → ` https://host/c/pets?sig=…&limit=20 ` . A ** fragment**
786+ on the base URL is rejected (` IllegalArgumentException ` ) — it cannot be composed with a path/query
787+ and is never sent on the wire — and a base URL that resolves to a malformed URL (e.g. no scheme)
788+ also throws ` IllegalArgumentException ` rather than leaking a checked ` MalformedURLException ` .
789+ - ** Headers / body / method** — set verbatim from the projections; ` Request.build() ` validates
790+ body/method compatibility.
791+
792+ ` toRequestContext ` builds the ` Request ` and promotes a ` DispatchContext ` into a ` RequestContext `
793+ carrying it, in one step. ` operationName ` (when set) is carried onto that ` RequestContext ` and
794+ forwarded down the chain to the ` ExchangeContext ` , so the tracing seam can label the operation; it
795+ never alters the assembled request. Execution stays the pipeline's job — the SPI stops at producing
796+ the request/context (error-mapping and deserialization compose at the service layer, not as pipeline
797+ stages).
798+
799+ ``` kotlin
800+ class ListPets (private val limit : Int? ) : OperationParams {
801+ override val method = Method .GET
802+ override val pathTemplate = " /pets"
803+ override fun queryParams () =
804+ QueryParams .builder().apply { limit?.let { set(" limit" , it.toString()) } }.build()
805+ }
806+
807+ val request = ListPets (limit = 20 ).toRequest(" https://api.example.com" ) // GET …/pets?limit=20
808+ val response = httpClient.execute(request)
809+ ```
810+
811+ ---
812+
688813## Design Decisions
689814
690815### Bodies Over the SDK's I/O Abstraction
@@ -749,6 +874,36 @@ Specific API choices driven by JDK 8 targeting:
749874| ` java.net.http.HttpClient ` (Java 11+) | ` HttpClient ` interface (transport-agnostic) |
750875| ` HttpHeaders ` (Java 11+) | Custom ` Headers ` class |
751876
877+ ### Request URL Model
878+
879+ ` Request ` stores its target as a single resolved ` java.net.URL ` (a string-backed container),
880+ ** not** a fully deconstructed URL value object (scheme / host / port / path-segments / query).
881+ Structured query manipulation is layered on top via the ` QueryParams ` multimap.
882+
883+ ** Decision: keep ` java.net.URL ` as the URL container; layer ` QueryParams ` for query
884+ manipulation.**
885+
886+ - ** DNS-free equality is preserved.** ` Request ` equality compares ` url.toExternalForm() ` — a
887+ pure string comparison with no network I/O — because ` java.net.URL.equals ` / ` hashCode `
888+ resolve the host via DNS (blocking, and wrong for virtual hosts sharing an address). Keeping
889+ the resolved-URL container carries that contract over unchanged.
890+ - ** The query is where the manipulation pressure is.** Pagination and (later) operation-input
891+ projection manipulate the query, not the host or path. ` QueryParams ` puts a structured,
892+ multi-valued, well-tested model exactly there, without forcing a rewrite of how transports
893+ consume a URL.
894+ - ** Transports already speak ` java.net.URL ` / strings.** Both reference transports accept a
895+ resolved URL or string directly; a deconstructed model would add an assembly step at every
896+ transport boundary for no functional gain today.
897+
898+ Path-template * substitution* (` /pets/{id} ` + values → an encoded path) lands minimally with the
899+ ` OperationParams ` SPI — see "Operation Input Projection" above. What remains ** deferred** is a
900+ * structured* URL model: a deconstructed ` Url ` value object and/or a move from ` java.net.URL ` to
901+ ` java.net.URI ` . ` URI ` gives DNS-free equality natively (no ` toExternalForm() ` workaround) and
902+ exposes the raw query and path, but parses more strictly and touches every transport boundary. The
903+ container choice (` URL ` vs ` URI ` vs deconstructed) is best decided when richer path handling
904+ (per-segment typing, matrix params) actually earns it; the minimal template substitution above does
905+ not require it.
906+
752907---
753908
754909## Usage Examples
@@ -860,6 +1015,10 @@ exchangeCtx.close()
8601015| ` NetworkException.kt ` | ` http.response.exception ` | public | Transport-level failure (IOException sibling)|
8611016| ` HttpExceptionFactory.kt ` | ` http.response.exception ` | public | ` Response ` → typed exception dispatcher |
8621017| ` Headers.kt ` | ` http.common ` | public | Immutable multi-map + builder |
1018+ | ` QueryParams.kt ` | ` http.common ` | public | Immutable query-string multi-map + builder |
1019+ | ` PercentEncoding.kt ` | ` http.common ` | internal | RFC 3986 URL-component percent-encoding (query + path) |
1020+ | ` OperationParams.kt ` | ` operation ` | public | SPI: project operation inputs → ` Request ` + context |
1021+ | ` OperationRequestAssembler.kt ` | ` operation ` | internal | Assembles a ` Request ` from an ` OperationParams ` |
8631022| ` MediaType.kt ` | ` http.common ` | public | Parsed MIME type with charset extraction |
8641023| ` CommonMediaTypes.kt ` | ` http.common ` | public | Media type constants |
8651024| ` Protocol.kt ` | ` http.common ` | public | HTTP protocol version enum |
0 commit comments