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
104 changes: 104 additions & 0 deletions vlib/net/http/h3_client.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module http

import net.quic

// This file converts between net.http's Request/Response and the HTTP/3
// client types H3ClientRequest/H3ClientResponse (h3_mux_conn.v -- Phase 12c
// already defined them there, since H3MuxConn needed a concrete shape to
// compile against before this file existed; mirrors how H2ClientRequest
// lives in h2_conn.v, not h2_client.v, which only converts to/from it). The
// actual transport wiring (UDP dial, QUIC handshake, pooling) lives in
// h3_udp_dial.v/transport_h3.v; these helpers are pure and backend
// agnostic, so they can be tested without a socket.

// to_h3_request builds an HTTP/3 request from this request. Header names
// are lowercased, hop-by-hop headers are dropped, the Host header becomes
// the :authority pseudo-header, and cookies are collapsed into a single
// field -- reuses h2_client.v's h2_hop_by_hop list and h2_authority helper
// directly (RFC 9114 §4.1.1 intentionally mirrors RFC 9113 §8.1.2.2 here,
// so there is nothing HTTP/3-specific to re-derive).
fn (req &Request) to_h3_request(method Method, authority string, path string, data string, header Header) H3ClientRequest {
// An explicit Host header overrides the URL host, matching the HTTP/1.1
// and HTTP/2 paths (used for virtual-host / host-override requests).
mut auth := authority
if host := header.get(.host) {
if host != '' {
auth = host
}
}
mut extra := []quic.QpackFieldLine{}
if !header.contains(.user_agent) && req.user_agent != '' {
extra << quic.QpackFieldLine{
name: 'user-agent'
value: req.user_agent
}
}
if data.len > 0 && !header.contains(.content_length) {
extra << quic.QpackFieldLine{
name: 'content-length'
value: data.len.str()
}
}
for key in header.keys() {
lkey := key.to_lower()
if lkey in h2_hop_by_hop {
continue
}
for val in header.custom_values(key) {
// RFC 9114 §4.2 carries HTTP/2's identical TE restriction (RFC
// 9113 §8.2.2): TE may be sent, but MUST NOT carry any value
// other than 'trailers'.
if lkey == 'te' && val.trim_space().to_lower() != 'trailers' {
continue
}
extra << quic.QpackFieldLine{
name: lkey
value: val
}
}
}
// Cookies: the request's own cookie map plus any Cookie header values,
// joined into one field (RFC 9114 §4.2 also permits splitting).
mut cookie_parts := []string{}
for k, v in req.cookies {
cookie_parts << '${k}=${v}'
}
for cv in header.values(.cookie) {
cookie_parts << cv
}
if cookie_parts.len > 0 {
extra << quic.QpackFieldLine{
name: 'cookie'
value: cookie_parts.join('; ')
}
}
return H3ClientRequest{
method: method.str()
scheme: 'https'
authority: auth
path: path
headers: extra
body: data.bytes()
}
}

// h3_response_to_http converts an HTTP/3 response into a net.http Response,
// decoding any Content-Encoding the same way the HTTP/1.1/2 paths do.
fn h3_response_to_http(h3resp H3ClientResponse) Response {
mut h := new_header()
for f in h3resp.headers {
h.add_custom(f.name, f.value) or {}
}
body := decode_response_body(h3resp.body.bytestr(), h.get(.content_encoding) or { '' })
status := status_from_int(h3resp.status)
return Response{
http_version: '3.0'
status_code: h3resp.status
status_msg: status.str()
header: h
body: body
}
}
114 changes: 114 additions & 0 deletions vlib/net/http/h3_client_test.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
module http

import net.quic

// Tests for the HTTP/3 <-> net.http conversion glue (h3_client.v). The
// request/response conversions are pure and need no socket -- unlike
// h2_client_test.v, there is no end-to-end fetch test here: no h3 test
// server exists in this repo (Phase 13, server support, is out of v1
// scope), so a real `fetch(enable_http3: true)` call has nothing to
// exercise it against. transport_h3_test.v covers what IS testable
// without a live peer (pool-key folding, the mTLS fast-fail, 3-way idle
// eviction with h3 entries, the singleflight dial-call struct itself).

fn test_to_h3_request_pseudo_headers_and_body() {
req := Request{
user_agent: 'v.http'
}
h3req := req.to_h3_request(.post, 'example.com', '/p?q=1', 'hello', new_header())
assert h3req.method == 'POST'
assert h3req.scheme == 'https'
assert h3req.authority == 'example.com'
assert h3req.path == '/p?q=1'
assert h3req.body.bytestr() == 'hello'
// user-agent (from the request) and a synthesized content-length.
assert h3req.headers.any(it.name == 'user-agent' && it.value == 'v.http')
assert h3req.headers.any(it.name == 'content-length' && it.value == '5')
}

fn test_to_h3_request_lowercases_and_keeps_custom_headers() {
mut h := new_header()
h.add_custom('Accept', 'application/json') or {}
h.add(.content_type, 'text/plain')
req := Request{}
h3req := req.to_h3_request(.get, 'h.example', '/', '', h)
assert h3req.headers.any(it.name == 'accept' && it.value == 'application/json')
assert h3req.headers.any(it.name == 'content-type' && it.value == 'text/plain')
}

fn test_to_h3_request_strips_hop_by_hop_and_host() {
mut h := new_header()
h.add(.connection, 'keep-alive')
h.add(.host, 'example.com')
h.add_custom('Transfer-Encoding', 'chunked') or {}
req := Request{}
h3req := req.to_h3_request(.get, 'example.com', '/', '', h)
assert !h3req.headers.any(it.name == 'connection')
assert !h3req.headers.any(it.name == 'host')
assert !h3req.headers.any(it.name == 'transfer-encoding')
}

fn test_to_h3_request_te_only_trailers() {
// RFC 9114 §4.2 carries HTTP/2's identical TE restriction (RFC 9113
// §8.2.2): TE may be sent, but only with the value 'trailers'.
req := Request{}
mut h := new_header()
h.add_custom('TE', 'gzip') or {}
h3req := req.to_h3_request(.get, 'h.example', '/', '', h)
assert !h3req.headers.any(it.name == 'te'), 'a non-trailers TE must be dropped'

mut h2 := new_header()
h2.add_custom('TE', 'trailers') or {}
h3req2 := req.to_h3_request(.get, 'h.example', '/', '', h2)
te := h3req2.headers.filter(it.name == 'te')
assert te.len == 1 && te[0].value == 'trailers', 'te: trailers must be kept'
}

fn test_to_h3_request_collapses_cookies() {
mut h := new_header()
h.add(.cookie, 'a=1')
req := Request{
cookies: {
'sid': 'abc'
}
}
h3req := req.to_h3_request(.get, 'h.example', '/', '', h)
cookie := h3req.headers.filter(it.name == 'cookie')
assert cookie.len == 1
// Both the request cookie map and the Cookie header value are present.
assert cookie[0].value.contains('sid=abc')
assert cookie[0].value.contains('a=1')
}

fn test_to_h3_request_authority_from_host_header() {
mut h := new_header()
h.add(.host, 'override.example:8443')
req := Request{}
// The URL host is origin.example, but an explicit Host header must win.
h3req := req.to_h3_request(.get, 'origin.example', '/', '', h)
assert h3req.authority == 'override.example:8443'
}

fn test_h3_response_to_http() {
h3resp := H3ClientResponse{
status: 200
headers: [
quic.QpackFieldLine{
name: 'content-type'
value: 'text/plain'
},
quic.QpackFieldLine{
name: 'x-foo'
value: 'bar'
},
]
body: 'hi'.bytes()
}
resp := h3_response_to_http(h3resp)
assert resp.status_code == 200
assert resp.http_version == '3.0'
assert resp.version() == .v3_0
assert resp.body == 'hi'
assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain'
assert (resp.header.get_custom('x-foo') or { '' }) == 'bar'
}
Loading
Loading