Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,14 +265,15 @@ See [docs/pipelines.md](docs/pipelines.md) for the step-author walkthrough.
| `http.request` | `Request`, `RequestBody`, `FileRequestBody`, `LoggableRequestBody`, `Method`. |
| `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>`. |
| `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`. |
| `http.common` | `Headers`, `HttpHeaderName` (interned), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
| `http.common` | `Headers`, `HttpHeaderName` (interned), `QueryParams` (RFC 3986 query multimap), `MediaType`, `Protocol`, `HttpRange`, `ETag`, `RequestConditions`. |
| `http.context` | `CallContext` → `DispatchContext` → `RequestContext` → `ExchangeContext` chain, `ContextStore`. |
| `http.pipeline` | Sync (`HttpStep` / `HttpPipeline` / `HttpPipelineBuilder` / `PipelineNext` / `Stage`) and async (`AsyncHttpStep` / `AsyncHttpPipeline` / `AsyncHttpPipelineBuilder` / `AsyncPipelineNext`) pipeline machinery, plus `AsyncPipelineBridges`. |
| `http.pipeline.steps` | Concrete steps: `RetryStep`, `RedirectStep`, `AuthStep`, `KeyCredentialAuthStep`, `BearerTokenAuthStep`, `InstrumentationStep`, `SetDateStep`, and their `*Options` / `*Condition` types. |
| `http.sse` | `ServerSentEventReader` (WHATWG spec), `ServerSentEvent`, `ServerSentEventListener`, `BufferedSource.readServerSentEvents()`. |
| `http.paging` | `PagedIterable<T>`, `PagedResponse<T>`, `PagingOptions` with `byPage()` and `stream()` accessors. |
| `auth` | `Credential` sealed hierarchy (`KeyCredential`, `NamedKeyCredential`, `BearerToken`), `BearerTokenProvider`, `AuthScheme`, per-operation `AuthRequirement` / `AuthDescriptor` with `AuthDescriptorResolver` precedence ladder, RFC 7235 challenge parser, `BasicChallengeHandler`, `DigestChallengeHandler`, `CompositeChallengeHandler`. |
| `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"`). |
| `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)`. |
| `pipeline` | Recovery-aware primitives: `RequestPipeline`, `ResponsePipeline`, `ExecutionPipeline` over a sealed `ResponseOutcome`, with steps (`pipeline.step`, `pipeline.step.retry`) like `RetryStep`, `ResponseRecoveryStep`, `IdempotencyKeyStep`, `ClientIdentityStep`. |
| `serde` | `Serde`, `Serializer`, `Deserializer` abstractions, `Tristate<T>` (absent / null / present), and `SerdeException` (the unchecked failure adapters translate codec errors into). |
| `io` | `Source`, `Sink`, `Buffer`, `BufferedSource`, `BufferedSink`, `IoProvider`, `Io`, `TeeSink`. |
Expand Down
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ concerns.
- [JDK 8 Compatibility](#jdk-8-compatibility)
- [Cross-Compile Toolchain Discipline](#cross-compile-toolchain-discipline)
- [Immutability and Builders](#immutability-and-builders)
- [Request URL Model](#request-url-model)
- [Virtual Thread Safety](#virtual-thread-safety)
- [Internal Visibility](#internal-visibility)
- [Cancellation](#cancellation)
Expand Down Expand Up @@ -596,6 +597,18 @@ data class Request private constructor(
- **`newBuilder()`**: Creates a pre-filled builder for modification
- **`Builder<out T>`**: Generic interface ensuring all builders have `fun build(): T`

### Request URL Model

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

### Virtual Thread Safety

The SDK uses `ReentrantLock` over `synchronized` wherever locking is needed:
Expand Down
159 changes: 159 additions & 0 deletions docs/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,67 @@ headers.get("content-type") // "application/json" (case-insensitive)
headers.values("Cache-Control") // ["no-cache", "no-store"]
```

### QueryParams

`QueryParams` is an immutable, insertion-ordered, multi-valued model of a URL query string —
the `?name=value&...` portion of a URL. It mirrors `Headers` in shape (private constructor,
mutable `Builder`, multi-value semantics) but differs in three ways: names are
**case-sensitive** (`?page=1` and `?Page=1` are distinct), values may be **empty or value-less**
(`?flag` and `?flag=` both occur in the wild), and equality is **order-sensitive** — two
instances are equal only if they `encode()` identically. (That last point is the one divergence
from `Headers`, whose case-folded names make name order non-semantic; here order is a rendered
property, so it counts.)

```kotlin
class QueryParams private constructor(
private val paramsMap: Map<String, List<String>>
)
```

**Role — building queries, not editing URLs.** `QueryParams` is an *origination* model: it
builds a query string from decoded names/values (for example, projecting an operation's inputs
into a request). It is **not** a fidelity-preserving editor of an existing URL. `encode()`
re-renders every parameter in canonical form, so round-tripping an arbitrary URL through `parse`
then `encode` can change the wire form of parameters you never touched (`?flag` → `flag=`,
reserved characters percent-encoded). Code that must edit one parameter of an existing URL while
leaving the rest byte-for-byte — pagination's `RequestRebuilder` — splices the raw query string
directly instead of going through `encode()`.

**API:**

| Method | Description |
|------------------|----------------------------------------------------------------------------|
| `get(name)` | First value for the name, or `null` if absent (`""` for a value-less param)|
| `values(name)` | All values for the name (unmodifiable), or empty list |
| `contains(name)` | Whether any value is present for the name |
| `names()` | Immutable, insertion-ordered snapshot of all parameter names |
| `entries()` | Immutable snapshot as `Map.Entry<String, List<String>>` |
| `size()` | Total number of values across all names (derived, not tracked) |
| `isEmpty()` | Whether there are no parameters |
| `encode()` | RFC 3986 query string (space → `%20`, literal `+` → `%2B`), no leading `?` |
| `newBuilder()` | Returns a pre-filled `Builder` for modification |

**Encoding.** `encode()` / `parse()` use **RFC 3986 query semantics** (via the internal
`PercentEncoding` helper): a space is `%20` (not `+`), and a literal `+` is `%2B` — it is **not**
read back as a space. This is deliberately *not* `application/x-www-form-urlencoded`: a query
*assembled as a request body* uses the form scheme (`+` for spaces) and will be a separate
form-body type, not `QueryParams.encode()`. `parse(encode(...))` round-trips names, values, and
order; malformed percent-encoding falls back to raw text rather than throwing.

**Builder:**

```kotlin
val params = QueryParams.builder()
.add("tag", "a")
.add("tag", "b") // multi-value
.set("page", "2") // replaces any existing "page"
.build()

params.values("tag") // ["a", "b"]
params.get("page") // "2"
params.encode() // "tag=a&tag=b&page=2"
```

### MediaType

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

---

## Operation Input Projection

`OperationParams` (`org.dexpace.sdk.core.operation`) is the SPI a thin generated service implements
once per operation to declare where each typed input belongs on the wire — **path**, **query**,
**header**, or **body** — so generated code (and typed pagination) never splices a URL string. The
runtime assembles the `Request` and feeds it into the context chain.

```kotlin
interface OperationParams {
val method: Method
val pathTemplate: String // "/pets/{petId}"; leading "/" optional
val operationName: String? // for the tracing seam; default null

fun pathParams(): Map<String, String> // default emptyMap()
fun queryParams(): QueryParams // default empty
fun headers(): Headers // default empty
fun body(): RequestBody? // default null

fun toRequest(baseUrl: String): Request
fun toRequestContext(baseUrl: String, dispatch: DispatchContext): RequestContext
}
```

Only `method` and `pathTemplate` are required; the four projections default to empty, so a
parameterless operation overrides almost nothing.

**Assembly** (`toRequest`):

- **Path** — each `{name}` in `pathTemplate` is replaced with its `pathParams()` value,
percent-encoded as a path segment (`/` → `%2F`), so a value cannot inject extra segments. A
`{name}` with no value throws `IllegalArgumentException`.
- **Query** — `queryParams().encode()` (RFC 3986) is appended after `?`.
- **Base URL** — the scheme/host/port/base-path carry through unchanged; a trailing `/` is trimmed
and exactly one `/` joins it to the resolved path, so `https://api.example.com/v1` + `/pets` →
`…/v1/pets`. A query already on the base URL is preserved: the resolved path is inserted **before**
it and the operation's query is appended after it, so a signed base
`https://host/c?sig=…` + `/pets?limit=20` → `https://host/c/pets?sig=…&limit=20`. A **fragment**
on the base URL is rejected (`IllegalArgumentException`) — it cannot be composed with a path/query
and is never sent on the wire — and a base URL that resolves to a malformed URL (e.g. no scheme)
also throws `IllegalArgumentException` rather than leaking a checked `MalformedURLException`.
- **Headers / body / method** — set verbatim from the projections; `Request.build()` validates
body/method compatibility.

`toRequestContext` builds the `Request` and promotes a `DispatchContext` into a `RequestContext`
carrying it, in one step. `operationName` (when set) is carried onto that `RequestContext` and
forwarded down the chain to the `ExchangeContext`, so the tracing seam can label the operation; it
never alters the assembled request. Execution stays the pipeline's job — the SPI stops at producing
the request/context (error-mapping and deserialization compose at the service layer, not as pipeline
stages).

```kotlin
class ListPets(private val limit: Int?) : OperationParams {
override val method = Method.GET
override val pathTemplate = "/pets"
override fun queryParams() =
QueryParams.builder().apply { limit?.let { set("limit", it.toString()) } }.build()
}

val request = ListPets(limit = 20).toRequest("https://api.example.com") // GET …/pets?limit=20
val response = httpClient.execute(request)
```

---

## Design Decisions

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

### Request URL Model

`Request` stores its target as a single resolved `java.net.URL` (a string-backed container),
**not** a fully deconstructed URL value object (scheme / host / port / path-segments / query).
Structured query manipulation is layered on top via the `QueryParams` multimap.

**Decision: keep `java.net.URL` as the URL container; layer `QueryParams` for query
manipulation.**

- **DNS-free equality is preserved.** `Request` equality compares `url.toExternalForm()` — a
pure string comparison with no network I/O — because `java.net.URL.equals` / `hashCode`
resolve the host via DNS (blocking, and wrong for virtual hosts sharing an address). Keeping
the resolved-URL container carries that contract over unchanged.
- **The query is where the manipulation pressure is.** Pagination and (later) operation-input
projection manipulate the query, not the host or path. `QueryParams` puts a structured,
multi-valued, well-tested model exactly there, without forcing a rewrite of how transports
consume a URL.
- **Transports already speak `java.net.URL` / strings.** Both reference transports accept a
resolved URL or string directly; a deconstructed model would add an assembly step at every
transport boundary for no functional gain today.

Path-template *substitution* (`/pets/{id}` + values → an encoded path) lands minimally with the
`OperationParams` SPI — see "Operation Input Projection" above. What remains **deferred** is a
*structured* URL model: a deconstructed `Url` value object and/or a move from `java.net.URL` to
`java.net.URI`. `URI` gives DNS-free equality natively (no `toExternalForm()` workaround) and
exposes the raw query and path, but parses more strictly and touches every transport boundary. The
container choice (`URL` vs `URI` vs deconstructed) is best decided when richer path handling
(per-segment typing, matrix params) actually earns it; the minimal template substitution above does
not require it.

---

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