diff --git a/docs/src/main/paradox/common/http-model.md b/docs/src/main/paradox/common/http-model.md index a9d693473..cd399bcf1 100644 --- a/docs/src/main/paradox/common/http-model.md +++ b/docs/src/main/paradox/common/http-model.md @@ -103,6 +103,16 @@ Scala Java : @@snip [ModelDocTest.java](/docs/src/test/java/docs/http/javadsl/ModelDocTest.java) { #synthetic-header-s3 } +The `Raw-Request-URI` header is honoured by both the HTTP/1.1 and the HTTP/2 client; over HTTP/2 its value is sent as +the `:path` pseudo-header. It is consumed by the request engine and never rendered as a header of its own, and its +value is used exactly as given — it is the caller's responsibility to supply a valid request target. + +This is the supported way to send a request target that @apidoc[Uri] cannot reproduce on its own. `Uri` percent-decodes +path segments when parsing and re-encodes them with a keep-set that leaves sub-delims raw, so an encoded *pchar* does +not survive the round trip — `%2B` is rendered back as `+`, for instance. Callers that must reproduce the target +byte-for-byte should pass it through this header. AWS SigV4 is a typical case: the signature covers the encoded path, +so an S3 object key containing `+` or `=` must reach the wire exactly as it was signed. + ## HttpResponse An @apidoc[HttpResponse] consists of diff --git a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala index 5e301267e..c70f1d0db 100644 --- a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala +++ b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/http2/HttpMessageRendering.scala @@ -20,6 +20,7 @@ import pekko.event.LoggingAdapter import pekko.http.impl.engine.http2.FrameEvent.ParsedHeadersFrame import pekko.http.impl.engine.rendering.DateHeaderRendering import pekko.http.scaladsl.model._ +import pekko.http.scaladsl.model.headers.`Raw-Request-URI` import pekko.http.scaladsl.settings.ClientConnectionSettings import pekko.http.scaladsl.settings.ServerSettings import pekko.util.OptionVal @@ -73,10 +74,21 @@ private[http2] class RequestRendering( headerPairs += ":method" -> request.method.value headerPairs += ":scheme" -> request.uri.scheme headerPairs += ":authority" -> request.uri.authority.toString - headerPairs += ":path" -> request.uri.toHttpRequestTargetOriginForm.toString + // a `Raw-Request-URI` header supplies the request target verbatim, as it does in the HTTP/1.1 renderer. `Uri` + // percent-decodes path segments when parsing and re-encodes them with a keep-set that leaves sub-delims raw, so + // it cannot round-trip an encoded pchar (`%2B` renders as `+`). Callers that must reproduce the target + // byte-for-byte -- AWS SigV4 signs the encoded path, for instance -- pass it through this header. + headerPairs += ":path" -> rawRequestTarget(request).getOrElse( + request.uri.toHttpRequestTargetOriginForm.toString) headerPairs } + // `Raw-Request-URI` is a SyntheticHeader, so it is already excluded from the rendered header block by the + // `renderInRequests` filter and is only consumed here. As in HTTP/1.1, the value is taken as given: it is the + // caller's responsibility that it is a valid origin-form target. + private def rawRequestTarget(request: HttpRequest): Option[String] = + request.headers.collectFirst { case `Raw-Request-URI`(rawUri) => rawUri } + override lazy val peerIdHeader: Option[(String, String)] = settings.userAgentHeader.map(h => h.lowercaseName -> h.value) diff --git a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala index d17c0185f..d4839f5f5 100644 --- a/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala +++ b/http2-tests/src/test/scala/org/apache/pekko/http/impl/engine/http2/Http2ClientSpec.scala @@ -44,6 +44,7 @@ import pekko.http.scaladsl.model.headers.{ `Cache-Control`, `Content-Length`, `Content-Type`, + `Raw-Request-URI`, RawHeader } import pekko.http.scaladsl.model.headers.CacheDirectives._ @@ -128,6 +129,41 @@ class Http2ClientSpec extends PekkoSpecWithMaterializer(""" expectedResponse = HPackSpecExamples.FirstResponse) }) + "send the Raw-Request-URI header verbatim as :path".inAssertAllStagesStopped( + new SimpleRequestResponseRoundtripSetup { + requestResponseRoundtrip( + streamId = 1, + // the `uri` is deliberately the lossy round-trip of the raw target, to show the header wins + request = HttpRequest( + uri = "https://www.example.com/a+b%20c", + headers = List(`Raw-Request-URI`("/a%2Bb%20c"))), + expectedHeaders = defaultExpectedHeaders.map { + case (":path", _) => ":path" -> "/a%2Bb%20c" + case other => other + }, + response = Seq( + HeadersFrame(streamId = 1, endStream = true, endHeaders = true, + HPackSpecExamples.C61FirstResponseWithHuffman, None)), + expectedResponse = HPackSpecExamples.FirstResponse) + }) + + "re-encode the path from the Uri when no Raw-Request-URI header is present".inAssertAllStagesStopped( + new SimpleRequestResponseRoundtripSetup { + requestResponseRoundtrip( + streamId = 1, + request = HttpRequest(uri = "https://www.example.com/a%2Bb%20c"), + // `Uri` decodes `%2B` when parsing and renders `+` back, since `+` is kept raw by the pchar keep-set. + // This is the round-trip loss that makes the `Raw-Request-URI` escape hatch necessary. + expectedHeaders = defaultExpectedHeaders.map { + case (":path", _) => ":path" -> "/a+b%20c" + case other => other + }, + response = Seq( + HeadersFrame(streamId = 1, endStream = true, endHeaders = true, + HPackSpecExamples.C61FirstResponseWithHuffman, None)), + expectedResponse = HPackSpecExamples.FirstResponse) + }) + "GOAWAY when the response has an invalid headers frame".inAssertAllStagesStopped(new TestSetup with NetProbes { val streamId = 0x1 user.emitRequest(HttpRequest(uri = "http://www.example.com/"))