This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/docs by this push:
new 5e231e822e feat: declarative next-gen @Remote/@RemoteOp features
(constant parts, call policy, @Url/baseUrl, @Multipart, format selection,
streaming)
5e231e822e is described below
commit 5e231e822e4c6c9786c4f6b299875a749ebbf0cf
Author: James Bognar <[email protected]>
AuthorDate: Fri Jun 26 08:50:08 2026 -0400
feat: declarative next-gen @Remote/@RemoteOp features (constant parts, call
policy, @Url/baseUrl, @Multipart, format selection, streaming)
Next-gen REST client (RestClient.remote) only; classic engine unchanged.
- Constant interface/method headers, queryData, formData; @Content(def)
defaults.
- Per-part serializer via @HttpPartMarshalling.
- Annotation call policy: interceptors, timeout, safe idempotent-only
retries, throwOnError.
- Dynamic @Url parameter + declarative baseUrl with http/https SSRF guard.
- Declarative @Multipart/@Part with streaming part sources.
- Per-method accept/contentType marshaller selection.
- Streaming request/response bodies (SerializerBody, ReaderBody) + response
stream lifecycle fix.
- fix: explicit jakarta.servlet.http.Part import in RequestFormParamList to
resolve @Part name collision.
- test: context-scoped ReadinessState in HealthServlet_Test for reused-JVM
isolation.
- docs: NextGenRestClient/Interceptors/RequestParts pages + 10.0.0 release
notes.
---
pages/release-notes/10.0.0.md | 17 ++
pages/topics/13.03.RequestParts.md | 29 ++-
pages/topics/13.09.Interceptors.md | 30 ++-
pages/topics/13.15.NextGenRestClient.md | 317 ++++++++++++++++++++++++++++++++
4 files changed, 391 insertions(+), 2 deletions(-)
diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index dfd4955293..f08d5facd9 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -318,6 +318,23 @@ Juneau 10.0 extends its request-boundary observability to
cover custom (non-requ
See the extended [Observability — Micrometer +
OpenTelemetry](/docs/topics/RestServerObservability) topic page.
+### juneau-rest-client / juneau-rest-common
+
+#### Next-generation remote-proxy declarative features
+
+Juneau 10.0 significantly expands what the [REST
proxy](/docs/topics/RestProxyBasics) annotation family (`@Remote`, `@RemoteOp`,
and the verb annotations `@RemoteGet` / `@RemotePost` / `@RemotePut` /
`@RemoteDelete` / `@RemotePatch`) can express declaratively. Every addition
below is honored by the **next-generation** proxy engine only
(`RestClient.remote(...)` → `RemoteClient`); the classic `getRemote(...)`
engine is unchanged and ignores these members.
+
+- **Constant part values** — `@Remote` (interface) and `@RemoteOp` / verb
annotations (method) gain `headers` / `queryData` / `formData` members that
emit always-applied constant headers, query parameters, and form-data fields on
every call, with no dummy parameter. Method-level constants take precedence
over interface-level ones, caller-supplied values still compose, and all values
resolve through `VarResolver.DEFAULT`. (Constant path values are out of scope;
`@Remote(headerList=…)` rem [...]
+- **`@Content(def=…)` honored** — the next-gen engine now applies both the
parameter-level body default (when the body argument is `null`) and a
param-less, method-level constant body, matching the classic engine.
+- **Per-part serializers** — `@HttpPartMarshalling(serializer=…)` is now wired
for outgoing query / header / path / form-data parts, with precedence parameter
› method › interface and a fallback to the default `OpenApiSerializer` when
absent. (Serializer side only; the parser member is not consumed by the
next-gen engine.)
+- **Declarative call policy** — `@Remote` and `@RemoteOp` / verb annotations
gain `interceptors()`, `timeout()`, `retries()`, `retryNonIdempotent()`, and
`throwOnError()`. Interceptors apply as a union (builder → interface → method);
timeout/retries/throwOnError scalars follow method › interface ›
builder-default precedence. Auto-retries are conservatively safe: triggered
only by connection failures or `429`/`5xx`, with exponential backoff, and gated
to idempotent verbs (POST/PATCH requi [...]
+- **Dynamic URL & base override** — a new `@Url` parameter annotation
(`org.apache.juneau.http.Url`) supplies the whole request URL at call time
(absolute replaces and bypasses the client root URL; relative resolves against
it), plus a declarative `baseUrl()` attribute on `@Remote` / `@RemoteOp` / verb
annotations that substitutes the authority+root while preserving the path and
`{var}` templating. Precedence: `@Url` › method `baseUrl` › interface `baseUrl`
› client `rootUrl`. URL/base o [...]
+- **Declarative multipart** — a new method-level `@Multipart` marker
(`org.apache.juneau.http.remote.Multipart`) plus a `@Part` parameter annotation
(`org.apache.juneau.http.Part`, with `name`/`value`/`fileName`/`contentType`)
build a `multipart/form-data` request from annotated parameters. Text,
`byte[]`, `File`, `InputStream`, `Reader`, `HttpBody`, and bean part sources
are accepted; file/stream/reader/bean parts stream rather than buffer. A method
is either multipart or single-`@Conte [...]
+- **Per-method format selection** — `contentType()` and `accept()` attributes
on `@Remote` / `@RemoteOp` / verb annotations drive marshaller **selection**
(not just header values). `contentType` selects the matching request serializer
and emits a single clean `Content-Type`; `accept` sets the `Accept` header and
acts as a fallback parser (the response `Content-Type` stays authoritative). A
no-match media type falls back to the default marshaller but still sends the
overridden label (vend [...]
+- **Streaming efficiency** — POJO and `Reader` request bodies now stream
straight to the wire instead of being buffered, and `Reader` / `InputStream`
return types hand the caller a lazy stream over the live response whose
connection is released on close.
+
+See the expanded [Declarative Remote-Proxy
Features](/docs/topics/NextGenRestClient#declarative-remote-proxy-features-next-gen)
section of the Next-Generation REST Client topic page for full examples and
precedence rules.
+
### juneau-marshall
#### Token-Streaming and Record-Streaming API
diff --git a/pages/topics/13.03.RequestParts.md
b/pages/topics/13.03.RequestParts.md
index 9a822dfb56..e20d2b9a56 100644
--- a/pages/topics/13.03.RequestParts.md
+++ b/pages/topics/13.03.RequestParts.md
@@ -51,4 +51,31 @@ RestClient client =
RestClient.create().header("Authorization", ()->getMyAuthTok
:::info See Also
- <a href="/site/apidocs/org/apache/juneau/http/header/package-summary.html"
target="_blank">org.apache.juneau.http.header</a> - Predefined <a
href="https://hc.apache.org/httpcomponents-core-4.4.x/current/httpcore/apidocs/org/apache/http/Header.html"
target="_blank">Header</a> beans.
-:::
\ No newline at end of file
+:::
+
+## Constant Parts on Remote-Proxy Interfaces
+
+The above methods add parts on a per-client or per-request basis. When using
the next-generation
+[remote-proxy](/docs/topics/RestProxyBasics) engine
(`RestClient.remote(...)`), you can also declare
+**always-applied constant** headers, query parameters, and form-data fields
directly on the proxy
+annotations — with no corresponding method parameter:
+
+- Interface-level: `@Remote(headers=…, queryData=…, formData=…)` — applied to
every method.
+- Method-level: `@RemoteOp(headers=…, queryData=…, formData=…)` (and the verb
annotations) — applied
+ to that method, taking precedence over an interface-level constant of the
same name.
+
+:::tip Example
+```java
+@Remote(path="/api", queryData="api_key=$S{petstore.apiKey}")
+public interface PetStore {
+
+ @RemoteGet(path="/pets", headers="X-View: full", queryData="view=full")
+ Pet[] getPets();
+}
+```
+:::
+
+These constants are honored by the next-gen engine only; the classic
`getRemote(...)` engine ignores
+them. See
+[Declarative Remote-Proxy
Features](/docs/topics/NextGenRestClient#constant-part-values) for the full
+contract (precedence, `VarResolver` support, and composition with
caller-supplied values).
\ No newline at end of file
diff --git a/pages/topics/13.09.Interceptors.md
b/pages/topics/13.09.Interceptors.md
index 83bda93d3e..26ecbd4c3e 100644
--- a/pages/topics/13.09.Interceptors.md
+++ b/pages/topics/13.09.Interceptors.md
@@ -44,4 +44,32 @@ RestClient client = RestClient
)
.build();
```
-:::
\ No newline at end of file
+:::
+
+## Annotation-Level Interceptors (Next-Gen Remote Proxies)
+
+When using the next-generation remote-proxy engine (`RestClient.remote(...)`),
interceptors can also
+be declared **declaratively** on the proxy annotations rather than only on the
builder. The
+`interceptors()` member is available on `@Remote` (interface-level) and on
`@RemoteOp` /
+`@RemoteGet` / `@RemotePost` / `@RemotePut` / `@RemoteDelete` / `@RemotePatch`
(method-level). Each
+class must implement `RestCallInterceptor` and provide a public no-arg
constructor.
+
+:::tip Example
+```java
+@Remote(path="/api", interceptors=AuthInterceptor.class) // applied to every
method
+public interface MyApi {
+
+ @RemoteGet(path="/audited", interceptors=AuditInterceptor.class) // plus
this method only
+ Data getData();
+}
+```
+:::
+
+The builder-configured, interface-level, and method-level interceptors are
applied as a **union** in
+the order **builder → interface → method** (the method-level interceptor runs
closest to the call).
+
+The same annotations also carry the rest of the declarative call policy —
per-call `timeout`, safe
+`retries` / `retryNonIdempotent`, and `throwOnError`. These are honored by the
next-gen engine only;
+the classic `getRemote(...)` engine ignores them. See
+[Declarative Remote-Proxy
Features](/docs/topics/NextGenRestClient#call-policy-interceptors-timeouts-retries-throwonerror)
+for the full call-policy contract.
\ No newline at end of file
diff --git a/pages/topics/13.15.NextGenRestClient.md
b/pages/topics/13.15.NextGenRestClient.md
index 96f402ce84..3029acc248 100644
--- a/pages/topics/13.15.NextGenRestClient.md
+++ b/pages/topics/13.15.NextGenRestClient.md
@@ -147,6 +147,323 @@ client.shutdown();
---
+## Declarative Remote-Proxy Features (Next-Gen)
+
+The next-generation engine adds a family of declarative capabilities to the
+[REST proxy](/docs/topics/RestProxyBasics) annotations (`@Remote`,
`@RemoteOp`, and the verb
+annotations `@RemoteGet` / `@RemotePost` / `@RemotePut` / `@RemoteDelete` /
`@RemotePatch`).
+
+:::warning Next-gen engine only
+Every feature in this section is honored **only** by the next-generation proxy
engine obtained via
+`RestClient.remote(MyProxy.class)` → `RemoteClient`. The classic engine
obtained via
+`RestClient.getRemote(...)` is unchanged and ignores these annotation members.
Where an attribute is
+visible on a shared annotation, its Javadoc states that the classic engine
currently ignores it.
+:::
+
+```java
+// Next-gen proxy — honors everything in this section.
+MyProxy proxy = client.remote(MyProxy.class);
+
+// Classic proxy — unchanged; ignores the next-gen-only members below.
+MyProxy classic = client.getRemote(MyProxy.class, "https://api.example.com");
+```
+
+### Constant Part Values
+
+`@Remote` (interface) and `@RemoteOp` / the verb annotations (method) can
declare **always-applied
+constant** headers, query parameters, and form-data fields that are emitted on
every call — with no
+corresponding method parameter and no dummy `null` argument.
+
+- Interface-level: `@Remote(headers=…, queryData=…, formData=…)`.
+- Method-level: `@RemoteOp(headers=…, queryData=…, formData=…)` (and the verb
annotations).
+- `headers` use the `"Name: value"` form; `queryData` / `formData` use the
`"name=value"` form.
+- All values resolve through `VarResolver.DEFAULT` (e.g.
`"$S{mySystemProperty}"`).
+- A method-level constant of the same name **takes precedence over** an
interface-level constant.
+- A caller-supplied parameter value of the same name still **composes** with
the constant (the
+ constant is not suppressed). This is distinct from parameter-level
+ [`def` defaults](/docs/topics/RestProxyBasics#default-values), which only
fill a `null` argument.
+
+:::note Constant path values are out of scope
+There is no constant path attribute at either level. Path segments remain
driven by the `path`
+template's `{var}` tokens and `@Path` parameters.
+:::
+
+```java
+@Remote(
+ path="/api",
+ queryData="api_key=$S{petstore.apiKey}" // every call carries ?api_key=…
+)
+public interface PetStore {
+
+ @RemoteGet(
+ path="/pets",
+ headers="X-View: full", // every getPets() call sends
this header
+ queryData="view=full" // …and ?view=full
+ )
+ Pet[] getPets();
+}
+```
+
+:::note `@Remote(headerList=…)` is classic-only
+Per design decision D1, the `@Remote(headerList=…)` member (a supplier of an
Apache-HttpClient
+`HeaderList`) remains **classic-only**. The next-gen engine does not consume
it — use the
+transport-agnostic `headers="Name: value"` form instead.
+:::
+
+### Content Body Defaults
+
+The next-gen engine honors `@Content(def=…)` — both the parameter-level
default (used when a body
+argument is `null`) and the param-less, method-level constant body (used when
a method has no body
+parameter at all). This matches the classic engine's behavior and the
documentation under
+[Default Values](/docs/topics/RestProxyBasics#default-values). Provided values
still win.
+
+```java
+@Remote(path="/petstore")
+public interface PetStore {
+
+ // Parameter-level default — fills a null argument.
+ @RemotePost("/pets")
+ Pet addPet(@Content(def="{name:'Unknown',price:0}") CreatePet pet);
+
+ // Param-less constant body — sent on every call.
+ @RemotePost("/pets/ping")
+ @Content(def="{}")
+ void ping();
+}
+```
+
+### Per-Part Serializers
+
+A specific request **part** (query / header / path / form-data) can be
marshalled with a custom
+`HttpPartSerializer` via `@HttpPartMarshalling(serializer=…)`, instead of the
engine's default
+`OpenApiSerializer`. The annotation can sit on a parameter, on the method, or
on the interface, with
+precedence **parameter › method › interface**; when absent, the existing
default serializer is used
+unchanged.
+
+```java
+@Remote(path="/api")
+public interface MyApi {
+
+ @RemoteGet("/search")
+ String search(
+ @Query("filter")
+ @HttpPartMarshalling(serializer=MyFilterPartSerializer.class) // this
part only
+ Filter filter
+ );
+}
+```
+
+:::note Serializer side only
+Only the serializer (request) side is wired in the next-gen engine; the
`parser` member is not
+consumed because the next-gen engine has no response-part parse path. This
applies to HTTP **parts**
+only — request *bodies* are handled by [per-method format
selection](#per-method-format-selection)
+and full serializers, not `HttpPartSerializer`.
+:::
+
+### Call Policy: Interceptors, Timeouts, Retries, throwOnError
+
+`@Remote` and `@RemoteOp` / the verb annotations can declare cross-cutting
call behavior
+declaratively, instead of configuring it only on the `RestClient` builder. See
also the
+[Interceptors](/docs/topics/Interceptors) page.
+
+| Member | Type | Behavior |
+|---|---|---|
+| `interceptors()` | `Class<?>[]` | Classes implementing the client
`RestCallInterceptor` SPI (public no-arg constructor). |
+| `timeout()` | `String` | Per-call response/read timeout as a duration string
(e.g. `"30s"`, `"1500ms"`). |
+| `retries()` | `int` | Maximum automatic retry attempts (`0` = disabled). |
+| `retryNonIdempotent()` | `boolean` | Opt `POST`/`PATCH` into auto-retry. |
+| `throwOnError()` | `boolean` | Throw a generic exception on an unmatched
error response. |
+
+```java
+@Remote(path="/api", interceptors=AuthInterceptor.class, timeout="30s")
+public interface MyApi {
+
+ @RemoteGet(path="/flaky", retries=3, throwOnError=true)
+ Data getData();
+
+ @RemotePost(path="/orders", retries=2, retryNonIdempotent=true)
+ Order createOrder(@Content Order order);
+}
+```
+
+**Interceptors** are applied as a **union** in the order **builder → interface
→ method** (the
+method-level interceptor runs closest to the call).
+
+**Timeout / retries / throwOnError** scalars follow **method › interface ›
builder default**
+precedence (`throwOnError` and `retryNonIdempotent` are the logical-OR of the
method and interface
+values). The per-call timeout is the response/read timeout; the connect
timeout remains a
+client-level setting.
+
+**Safe retries** are deliberately conservative (hard gates, all must pass):
+
+- **Trigger** — a connection failure, or a retryable HTTP status (`429` or any
`5xx`), with a short
+ exponential backoff between attempts.
+- **Idempotent verbs only** — `GET`/`PUT`/`DELETE`/`HEAD` auto-retry;
`POST`/`PATCH` retry only when
+ `retryNonIdempotent=true`.
+- **Repeatable bodies only** — a request whose body is not repeatable (e.g. a
streaming
+ `InputStream` / `Reader` body) is never resent. A serialized-POJO body
**is** repeatable.
+- **Buffered return modes only** — retries are disabled for `RESPONSE`, raw
`InputStream` / `Reader`
+ returns, streaming cursors, and `Future` / `CompletableFuture` returns.
+
+**`throwOnError`** composes *after* the typed-exception mapping from the
method's `throws` clause: a
+declared exception type whose status matches the response is still thrown in
preference; only when no
+declared type matches and the status is `>=400` does the generic exception
fire.
+
+### Dynamic URLs and Base Overrides
+
+Two complementary ways to point a call at a runtime- or annotation-chosen
endpoint:
+
+- **`@Url` parameter** (`org.apache.juneau.http.Url`) — the Retrofit `@Url`
pattern. The argument's
+ value becomes the effective URL for that single call. A value containing a
scheme (`://`) is
+ **absolute** — it replaces the whole endpoint and bypasses the client root
URL; a value with no
+ scheme is **relative** — it resolves against the client root URL only (not
against the interface
+ `@Remote(path)`). `{var}` tokens are still filled by `@Path` parameters. At
most one `@Url` per
+ method; a `null`/blank value is rejected.
+- **`baseUrl()` attribute** on `@Remote` / `@RemoteOp` / the verb annotations
— substitutes only the
+ authority+root of the URL while **preserving** the interface base path, the
method path, and
+ `{var}` templating. Resolves through `VarResolver.DEFAULT`.
+
+Precedence (most-specific first): **`@Url` parameter › method `baseUrl` ›
interface `baseUrl` →
+client `rootUrl`**.
+
+```java
+@Remote(path="/api", baseUrl="$S{myservice.baseUrl}")
+public interface MyApi {
+
+ // Caller chooses the endpoint at runtime.
+ @RemoteGet
+ String fetch(@Url String url);
+
+ // Overrides the host for this one method only.
+ @RemoteGet(path="/health", baseUrl="http://health-host:8080")
+ String health();
+}
+```
+
+:::warning SSRF guardrail
+Only `http` / `https` schemes are permitted on a URL/base override. Other
schemes (`file:`,
+`gopher:`, `jar:`, …) are rejected with a clear error.
+:::
+
+### Declarative Multipart
+
+Beyond the [fluent multipart API](#multipart-file-uploads), the next-gen
engine supports a
+Retrofit-style **declarative** `multipart/form-data` surface:
+
+- `@Multipart` (`org.apache.juneau.http.remote.Multipart`) — a method-level
marker. The
+ `multipart/form-data` `Content-Type` (with a generated boundary) is applied
automatically.
+- `@Part` (`org.apache.juneau.http.Part`) — a parameter annotation with
`name()` / `value()`
+ (synonym), `fileName()`, and `contentType()` members.
+
+```java
+@Remote(path="/api")
+public interface UploadService {
+
+ @RemotePost("/upload")
+ @Multipart
+ String upload(
+ @Part("title") String title,
+ @Part(name="attachment", fileName="report.pdf",
contentType="application/pdf") byte[] data
+ );
+}
+```
+
+Accepted part-source types (mapped automatically from the argument type):
+
+| Argument type | Part behavior |
+|---|---|
+| `String` / `CharSequence` / scalars | Text field |
+| `byte[]` | Binary, sent verbatim (repeatable) |
+| `File` | **Streamed**; `fileName` defaults to the file's name |
+| `InputStream` | **Streamed** (one-shot; makes the body non-repeatable) |
+| `Reader` | **Streamed** as UTF-8 (one-shot) |
+| `HttpBody` | Used directly |
+| any other object (bean) | Serialized with the client's default serializer |
+
+A `null` `@Part` argument contributes no part. File / stream / reader / bean
parts **stream** (they
+reuse the streaming bodies described under [Streaming
Bodies](#streaming-request-and-response-bodies)).
+
+**Body-mode exclusivity** is validated at proxy-build time
(`IllegalArgumentException`): a method is
+either multipart (`@Multipart` + one or more `@Part`) **or** single-body
(`@Content`), never both;
+`@Multipart` with no `@Part`, or `@Part` without `@Multipart`, is also
rejected.
+
+:::tip Escape hatch (Option B)
+For advanced cases you can still hand-assemble a `MultipartBody` and pass it
as a single `@Content`
+body without `@Multipart` — that path is unchanged.
+:::
+
+### Per-Method Format Selection
+
+`@Remote` (interface default) and `@RemoteOp` / the verb annotations (method)
expose `contentType()`
+and `accept()` media-type attributes that drive **marshaller selection** — not
merely header values.
+This lets a single method switch its actual wire format without changing the
client default for every
+other call (e.g. one legacy XML endpoint on an otherwise-JSON API, or a binary
format on a few
+endpoints). See also [Content-Type
Negotiation](/docs/topics/ContentTypeNegotiation).
+
+- **`contentType`** selects the matching registered **request serializer**
from the client's
+ serializer set (so the body bytes are actually written in that format) and
sets a single, clean
+ `Content-Type` header — replacing the serializer's default content type with
no duplicate header.
+- **`accept`** sets the `Accept` header and acts as a **fallback parser**
only. The **response**
+ `Content-Type` stays authoritative for parser selection; the `accept` media
type is used only when
+ the response is unlabeled or its `Content-Type` matches no registered parser.
+- **No-match media type** falls back to the default marshaller but still sends
the overridden label
+ (supporting vendor types such as `application/vnd.example.v2+json`).
+
+Precedence: **method › interface › client default**. A dedicated
`contentType`/`accept` attribute
+beats a [constant](#constant-part-values) `Content-Type`/`Accept` header (the
constant is dropped, no
+duplicate), but a genuinely caller-supplied `@Header("Content-Type"/"Accept")`
parameter still wins.
+
+```java
+@Remote(path="/api") // interface defaults to the client's JSON
marshaller
+public interface MyApi {
+
+ @RemoteGet("/items")
+ Item[] getItems(); // JSON request/response
+
+ // This one legacy endpoint speaks XML in both directions.
+ @RemotePost(path="/legacy", contentType="application/xml",
accept="application/xml")
+ Result postLegacy(@Content Payload payload);
+}
+```
+
+### Streaming Request and Response Bodies
+
+The next-gen engine streams large bodies in both directions instead of
buffering them in memory,
+matching the classic engine's efficiency. See also [Streaming
Cursors](/docs/topics/StreamingCursors).
+
+- **Streaming POJO request bodies** — a POJO `@Content` body is serialized
straight to the wire
+ (chunked, `Content-Length: -1`) rather than pre-serialized to a
`String`/`byte[]`. This body is
+ **repeatable**, so it is safe to auto-retry.
+- **Streaming `Reader` request bodies** — a `Reader` `@Content` body streams
as UTF-8 rather than
+ being drained to a `String` first. (Non-repeatable — never auto-retried.)
+- **`Reader` and `InputStream` return types** — a method may declare a
`Reader` or `InputStream`
+ return type and receive a lazy stream over the live response. The caller
**owns** the stream; the
+ underlying connection is released when the caller closes it. Always close
the returned stream
+ (try-with-resources).
+
+```java
+@Remote(path="/api")
+public interface MyApi {
+
+ // Streams the POJO body straight to the socket.
+ @RemotePost("/bulk")
+ void bulkUpload(@Content List<Record> records);
+
+ // Caller owns the returned stream — close it to release the connection.
+ @RemoteGet("/export")
+ InputStream export();
+}
+```
+
+```java
+MyApi api = client.remote(MyApi.class);
+try (InputStream in = api.export()) {
+ in.transferTo(System.out);
+}
+```
+
+---
+
## Multipart File Uploads
`multipart/form-data` is a first-class body type: