Skip to content

Commit 2f9c0d1

Browse files
authored
feat: add QueryParams multimap and OperationParams input-projection SPI (#200)
PR: #200
1 parent dba4cc2 commit 2f9c0d1

20 files changed

Lines changed: 1655 additions & 108 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/architecture.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ concerns.
2828
- [JDK 8 Compatibility](#jdk-8-compatibility)
2929
- [Cross-Compile Toolchain Discipline](#cross-compile-toolchain-discipline)
3030
- [Immutability and Builders](#immutability-and-builders)
31+
- [Request URL Model](#request-url-model)
3132
- [Virtual Thread Safety](#virtual-thread-safety)
3233
- [Internal Visibility](#internal-visibility)
3334
- [Cancellation](#cancellation)
@@ -596,6 +597,18 @@ data class Request private constructor(
596597
- **`newBuilder()`**: Creates a pre-filled builder for modification
597598
- **`Builder<out T>`**: Generic interface ensuring all builders have `fun build(): T`
598599

600+
### Request URL Model
601+
602+
`Request` stores its target as a single resolved `java.net.URL` and layers the `QueryParams`
603+
multimap (`http.common`) on top for structured query manipulation, rather than modelling a fully
604+
deconstructed URL value object. This preserves the DNS-free equality contract — `Request` compares
605+
`url.toExternalForm()`, a pure string comparison, because `java.net.URL.equals` / `hashCode`
606+
resolve the host via DNS (blocking, and wrong for virtual hosts) — and puts a structured model
607+
where the manipulation pressure actually is (the query string). A deconstructed `Url` model and/or
608+
a `java.net.URI` migration remain deferred until richer path handling earns them; minimal
609+
path-template substitution lives in the `OperationParams` SPI. Full rationale and the alternatives
610+
weighed: see the "Request URL Model" section in [`http.md`](http.md).
611+
599612
### Virtual Thread Safety
600613

601614
The SDK uses `ReentrantLock` over `synchronized` wherever locking is needed:

docs/http.md

Lines changed: 159 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,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

Comments
 (0)