diff --git a/docs/Request.html b/docs/Request.html
index cd4dc8b..73f33eb 100644
--- a/docs/Request.html
+++ b/docs/Request.html
@@ -943,6 +943,14 @@
stringifies the request as it looks on the wire.
+The request-target is rendered in origin-form: the path and query of the
+request’s URI, or / when it has neither, as RFC 9112 §3.2.1 requires of a
+request sent directly to an origin server. Scheme, host, port, userinfo and
+fragment stay on the URI and never reach the request line, so the authority has
+to travel in a Host header.
+The other request-target forms of RFC 9112 §3.2 are not produced:
+absolute-form addresses a proxy, authority-form a CONNECT, and asterisk-form
+a server-wide OPTIONS.
diff --git a/http.carp b/http.carp
index 495a035..734aaf2 100644
--- a/http.carp
+++ b/http.carp
@@ -312,22 +312,27 @@ attribute, whatever order the two appear in.")
(private target)
(hidden target)
- ; RFC 9110 §4.2.4: a request target never carries userinfo.
- ; RFC 9110 §7.1: the target URI excludes the fragment component.
- ; RFC 9112 §3.2.1: an origin-form target with an empty path is sent as `/`.
+ ; RFC 9112 §3.2.1: a request to an origin server carries path and query only.
(defn target [u]
- (let [v (URI.set-fragment
- (URI.set-password (URI.set-user @u (Maybe.Nothing)) (Maybe.Nothing))
- (Maybe.Nothing))
- s (URI.str &v)]
- (if (and (Maybe.nothing? (URI.path &v))
- (Maybe.nothing? (URI.scheme &v))
- (Maybe.nothing? (URI.host &v))
- (Maybe.nothing? (URI.port &v)))
- (String.append "/" &s)
- s)))
-
- (doc str "stringifies the request as it looks on the wire.")
+ (let [pth (Maybe.from @(URI.path u) @"")
+ q (Maybe.from @(URI.query u) @"")]
+ (String.concat
+ &[@(if (String.byte-starts-with? &pth "/") "" "/")
+ pth
+ @(if (String.empty? &q) "" "?")
+ q])))
+
+ (doc str "stringifies the request as it looks on the wire.
+
+The request-target is rendered in origin-form: the path and query of the
+request’s URI, or `/` when it has neither, as RFC 9112 §3.2.1 requires of a
+request sent directly to an origin server. Scheme, host, port, userinfo and
+fragment stay on the URI and never reach the request line, so the authority has
+to travel in a `Host` header.
+
+The other request-target forms of RFC 9112 §3.2 are not produced:
+absolute-form addresses a proxy, authority-form a `CONNECT`, and asterisk-form
+a server-wide `OPTIONS`.")
(defn str [r]
(let-do [sb (StringBuf.create)]
(StringBuf.append-str &sb (verb r))
diff --git a/test/http.carp b/test/http.carp
index e5a5085..13e1f50 100644
--- a/test/http.carp
+++ b/test/http.carp
@@ -473,7 +473,7 @@
"request-target of a query-only URI keeps its leading /")
(assert-equal test
- "GET http://example.com HTTP/1.1\r\n\r\n"
+ "GET / HTTP/1.1\r\n\r\n"
&(Request.str
&(Request.get
(URI.set-scheme
@@ -482,22 +482,43 @@
[]
{}
@""))
- "absolute-form request-target is left alone")
+ "request-target of an absolute URL drops the scheme and host")
+
+ (assert-equal test
+ "GET /a/b?q=1 HTTP/1.1\r\n\r\n"
+ &(request-line "http://user:pw@h.example:8080/a/b?q=1#sec2")
+ "request-target keeps only the path and query")
+
+ (assert-equal test
+ "GET /a/b HTTP/1.1\r\n\r\n"
+ &(Request.str
+ &(Request.get (URI.set-path (URI.zero) (Maybe.Just @"/a/b")) [] {} @""))
+ "an already absolute path is not slashed twice")
+
+ (assert-true test
+ (match (URI.parse "http://h.example:8080/a/b")
+ (Result.Success u)
+ (let [r (Request.get u [] {} @"")]
+ (and
+ (= &(Maybe.Just @"h.example") (URI.host (Request.uri &r)))
+ (= &(Maybe.Just 8080) (URI.port (Request.uri &r)))))
+ _ false)
+ "the host and port stay on the request’s URI")
(assert-equal test
- "GET http://h.example/a/c HTTP/1.1\r\n\r\n"
+ "GET /a/c HTTP/1.1\r\n\r\n"
&(request-line "http://USER:PW@h.example/a/c")
"request-target drops user and password")
(assert-equal test
- "GET http://h.example/a/c HTTP/1.1\r\n\r\n"
+ "GET /a/c HTTP/1.1\r\n\r\n"
&(request-line "http://USER@h.example/a/c")
"request-target drops a user without a password")
(assert-equal test
- "GET http://h.example/a/c HTTP/1.1\r\n\r\n"
+ "GET /a/c HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/c")
- "request-target without userinfo is unchanged")
+ "request-target without userinfo is the plain path")
(assert-equal test
"GET / HTTP/1.1\r\n\r\n"
@@ -516,27 +537,27 @@
"the credentials stay on the request’s URI")
(assert-equal test
- "GET http://h.example/a/b?q=1 HTTP/1.1\r\n\r\n"
+ "GET /a/b?q=1 HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/b?q=1#sec2")
"request-target drops a fragment and keeps the query")
(assert-equal test
- "GET http://h.example/a/b HTTP/1.1\r\n\r\n"
+ "GET /a/b HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/b#sec2")
"request-target drops a fragment without a query")
(assert-equal test
- "GET http://h.example/a/b HTTP/1.1\r\n\r\n"
+ "GET /a/b HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/b#")
"request-target drops an empty fragment")
(assert-equal test
- "GET http://h.example/a/b HTTP/1.1\r\n\r\n"
+ "GET /a/b HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/b#frag?notquery")
"request-target drops a fragment that looks like a query")
(assert-equal test
- "GET http://h.example/a/b?q=a%23b HTTP/1.1\r\n\r\n"
+ "GET /a/b?q=a%23b HTTP/1.1\r\n\r\n"
&(request-line "http://h.example/a/b?q=a%23b")
"request-target keeps an encoded # inside the query")