///|
priv enum FetchProtocol {
Http
Https
}
///|
fn FetchProtocol::default_port(self : FetchProtocol) -> Int {
match self {
Http => 80
Https => 443
}
}
///|
fn FetchProtocol::scheme(self : FetchProtocol) -> String {
match self {
Http => "http"
Https => "https"
}
}
///|
fn request_method_to_async(
http_method : HttpMethod,
) -> @http.RequestMethod raise Error {
match http_method {
Get => Get
Post => Post
Put => Put
Patch => Patch
Delete => Delete
Head => Head
Options => Options
Trace => Trace
Connect => Connect
Other(m) => raise FetchError::RequestFailed("unsupported HTTP method: \{m}")
}
}
///|
fn response_headers_from_async(
headers : Map[String, String],
) -> Map[String, String] {
let copied : Map[String, String] = {}
headers.each((key, value) => copied.set(key, value))
copied
}
/// Converts a BytesView (from the uri package) to a String by UTF-8 decoding.
///|
fn bytesview_to_string(bv : BytesView) -> String raise Error {
@utf8.decode(Bytes::from_iter(bv.iter())) catch {
_ => raise FetchError::RequestFailed("non-UTF-8 URL component")
}
}
///|
fn resolve_fetch_url(
url : String,
) -> (FetchProtocol, String, Int, String) raise Error {
// Parse the URL using the proper RFC 3986 parser from the uri package.
let parsed = @uri.Uri::parse(@utf8.encode(url)[:]) catch {
_ => raise FetchError::RequestFailed("invalid URL: \{url}")
}
// Extract and validate scheme (must be http or https).
let scheme_bv = match parsed.scheme {
Some(s) => s
None => raise FetchError::RequestFailed("invalid URL: \{url} (no scheme)")
}
let scheme = bytesview_to_string(scheme_bv).to_lower()
let protocol = match scheme {
"http" => Http
"https" => Https
other => raise FetchError::RequestFailed("unsupported protocol: \{other}")
}
// Extract authority (host + port).
let authority = match parsed.authority {
Some(a) => a
None => raise FetchError::RequestFailed("invalid URL: \{url} (no host)")
}
// Reject URLs with userinfo — we don't silently drop credentials.
// Users should pass credentials via the `headers` param (e.g. Authorization).
match authority.userinfo {
Some(_) =>
raise FetchError::RequestFailed(
"URL userinfo (user:pass@) is not supported; use the Authorization header instead",
)
None => ()
}
// Extract host: IPv6 literals need to be wrapped in brackets for the
// HTTP Host header and URL reconstruction.
let host = match authority.host {
IPv6Address(addr) => "[" + bytesview_to_string(addr) + "]"
RegName(name) => bytesview_to_string(name)
}
guard host != "" && host != "[]" else {
raise FetchError::RequestFailed("invalid URL: \{url} (empty host)")
}
// Extract port, defaulting to protocol default if not specified.
let port = match authority.port {
Some(p) => {
guard p is (1..<65536) else {
raise FetchError::RequestFailed("invalid URL: \{url} (bad port)")
}
p
}
None => protocol.default_port()
}
// Reconstruct the request path from segments and query.
// The path segments array always has a leading empty segment for "/".
let path_buf = StringBuilder::new()
for seg in parsed.path {
path_buf.write_string("/")
path_buf.write_string(bytesview_to_string(seg))
}
let mut path_str = path_buf.to_string()
if path_str == "" {
path_str = "/"
}
match parsed.query {
Some(q) => path_str = path_str + "?" + bytesview_to_string(q)
None => ()
}
(protocol, host, port, path_str)
}
///|
fn fetch_origin(protocol : FetchProtocol, host : String, port : Int) -> String {
if port == protocol.default_port() {
"\{protocol.scheme()}://\{host}"
} else {
"\{protocol.scheme()}://\{host}:\{port}"
}
}
///|
fn prepare_fetch_payload_bytes(
body : String?,
data : &Responder?,
headers : Map[String, String]?,
) -> (Bytes?, Map[String, String]) {
let req_headers = copy_headers(headers.unwrap_or({}))
let req_body = match body {
Some(raw_body) => Some(@utf8.encode(raw_body))
None =>
match data {
Some(responder_data) => {
let res = HttpResponse(OK)
responder_data.options(res)
res.headers.each((key, value) => {
if !@mhttp.has_header_case_insensitive(req_headers, key) {
req_headers.set(key.to_string(), value.to_string())
}
})
let body_buf = @buffer.new()
responder_data.output(body_buf)
Some(body_buf.to_bytes())
}
None => None
}
}
(req_body, req_headers)
}
///|
/// Sends an HTTP request to the given URL and returns the response.
pub async fn fetch(
url : String,
body? : String,
http_method : HttpMethod,
data? : &Responder,
headers? : Map[String, String],
credentials? : FetchCredentials,
mode? : FetchMode,
) -> HttpResponse raise Error {
ignore(credentials)
ignore(mode)
let (req_body, req_headers) = prepare_fetch_payload_bytes(body, data, headers)
let (protocol, host, port, path) = resolve_fetch_url(url)
let client = @http.Client::new(fetch_origin(protocol, host, port)) catch {
err => raise FetchError::RequestFailed("\{err}")
}
defer client.close()
let response = try {
client.request(
request_method_to_async(http_method),
path,
extra_headers=req_headers,
)
match req_body {
Some(body_bytes) => client.write(body_bytes)
None => ()
}
client.end_request()
} catch {
err => raise FetchError::RequestFailed("\{err}")
}
let response_body = client.read_all().binary() catch {
err => raise FetchError::RequestFailed("\{err}")
}
HttpResponse(
StatusCode::from_int(response.code),
headers=response_headers_from_async(response.headers),
raw_body=response_body,
)
}