/
nv-lang
/
nova-http
Обзор
Документация
Войти
/
nv-lang
/
nova-http
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
main
src/client/client.nv
673 строки
27 KB
Evgeniy Golovin
fix(client): bind parsed response mut, not ro, before the redirect-terminal finalize_response call
09 авг 2026, 23:02
09 авг 2026, 23:02
9f137ee
Код
Авторство
О чём код?
// SPDX-License-Identifier: MIT OR Apache-2.0 // nova-http: client.nv — HttpClient + reqwest-style builder (D360). Extracted // from monorepo std/http (Plan 203 Ф.1). // // Pure-Nova client logic over the thin `Http` seam (effect.nv): request build, // redirect-loop with cross-origin auth/cookie-strip, 4xx/5xx-as-valid-Response, // verb shortcuts and script convenience fns. Transport (connect/write/read) is // real_http (real.nv); tests use mock_http (mock.nv) — deterministic, no sockets. // // Landed (CORE, plaintext HTTP/1.1): // · HttpClient + HttpClientBuilder (default headers, redirect policy, UA). // · RequestBuilder: get/post/put/delete/patch/head + request; header/append_header, // query, body/text/json(dynamic)/form, bearer_auth/basic_auth; send/into_client. // · Redirect following (limit + TooManyRedirects; 303/301/302→GET-ify; // 307/308 preserve; cross-origin Authorization+Cookie strip). // · Content-Length (identity) + Transfer-Encoding: chunked (wire.nv). // · 4xx/5xx = valid Response; opt-in error_for_status (response_ext.nv). // // Honest gates (markers, not silent simplifications): // · timeout/deadline-by-default — substrate present but no effect-poly deadline // combinator / supervised(deadline:) in main. [M-178-timeout-needs-173] (refined) // · auto-decompress gzip/deflate — decode exists (Plan 179) but wiring compress // into this CU collides http/compress `ErrorKind` C-mangling (codegen). // [M-178-autodecompress-needs-179] → [M-codegen-nominal-type-name-collision] // · typed JSON bodies — LANDED as `json_decode_body[T]` in http.serdejson // (free-fn, isolated CU). [M-178-typed-json-needs-180] closed. // · https/h2 ← Plan 116 (real_http returns Err(Tls) for https). [M-178-https-needs-116] // · live keep-alive/pool reuse — decision logic + config here; live socket reuse // deferred (real.nv). [M-178-client-live-pool] // · Proxy/CONNECT-tunnel, SSRF-guard, cookie-jar, idempotent-retry — client // policy surface deferred beyond CORE. [M-178-client-policy-surface] module http.client import http.{Method, Url, HeaderMap, Response, Request, Http, HttpError, ErrorKind, Version, StatusCode} import std.encoding.base64.{Base64} import std.encoding.json.{JsonValue, Json} import compress.{gzip_decode, zlib_decode, inflate, brotli_decode, CompressError} // ── RedirectPolicy ─────────────────────────────────────────────────────────── /// Redirect-follow policy. `NoFollow` returns 3xx as-is; `Limited(n)` follows up /// to `n` hops then `Err(TooManyRedirects)`. (`None` avoided — collides with /// `Option.None` in importer namespace, Ф.1 convention.) #stable(since = "0.1") export type RedirectPolicy enum NoFollow | Limited(int) // ── HttpClient / builder ───────────────────────────────────────────────────── type HeaderKV value { ro name str, ro value str } // Default decompressed-body cap (DoS/bomb guard, D334): 64 MiB. Overridable via // `HttpClientBuilder.max_decompressed(n)`; a per-request `.limit()` on the Body // is a separate, complementary guard on the DECODED size. const DEFAULT_MAX_DECOMPRESSED = 67108864 /// Pooled HTTP client (cheap value; shares config). Build via `.builder()` or /// `.new()` for production defaults. #stable(since = "0.1") export type HttpClient value { priv default_headers []HeaderKV priv follow_redirects bool priv max_redirects int priv user_agent str priv auto_decompress bool priv max_decompressed int } /// Fluent client builder. #stable(since = "0.1") export type HttpClientBuilder value { priv default_headers []HeaderKV priv follow_redirects bool priv max_redirects int priv user_agent str priv auto_decompress bool priv max_decompressed int } /// Start a client builder with production defaults (10 redirects, UA set, /// transparent gzip/deflate decompression on with a 64 MiB decoded cap). #stable(since = "0.1") export fn HttpClient.builder() -> HttpClientBuilder => { default_headers: []HeaderKV.new(), follow_redirects: true, max_redirects: 10, user_agent: "nova-http/0.1", auto_decompress: true, max_decompressed: DEFAULT_MAX_DECOMPRESSED } /// Client with production defaults. #stable(since = "0.1") export fn HttpClient.new() -> HttpClient => { default_headers: []HeaderKV.new(), follow_redirects: true, max_redirects: 10, user_agent: "nova-http/0.1", auto_decompress: true, max_decompressed: DEFAULT_MAX_DECOMPRESSED } // Internal self-accessors (field privacy is enforced at the call-site module, // so cross-type reads of these `priv` fields go through owner methods, D281). fn HttpClient @cfg_follow() -> bool => @follow_redirects fn HttpClient @cfg_max() -> int => @max_redirects fn HttpClient @cfg_ua() -> str => @user_agent fn HttpClient @cfg_defaults() -> []HeaderKV => @default_headers fn HttpClient @cfg_auto_decompress() -> bool => @auto_decompress fn HttpClient @cfg_max_decompressed() -> int => @max_decompressed /// Add a default header sent on every request (append). Mutating fleeting /// property (D117 AMEND-2): a copying builder would shallow-copy the /// heap-backed `default_headers` list, sharing it with the pre-call builder — /// canon is mutate-in-place + `-> @`. #stable(since = "0.1") export fn HttpClientBuilder mut @default_header(name str, value str) -> @ { @default_headers.push(HeaderKV { name, value }) } /// Set the redirect policy (default `Limited(10)`). #stable(since = "0.1") export fn HttpClientBuilder mut @redirect(policy RedirectPolicy) -> @ { match policy { NoFollow => { @follow_redirects = false @max_redirects = 0 } Limited(n) => { @follow_redirects = true @max_redirects = n } } } /// Override the User-Agent. #stable(since = "0.1") export fn HttpClientBuilder mut @user_agent(ua str) -> @ { @user_agent = ua } /// Disable transparent gzip/deflate decompression. With decompression OFF the /// client sends NO default `Accept-Encoding` and returns response bodies verbatim /// (a compressed body stays compressed) — the caller owns Content-Encoding. #stable(since = "0.1") export fn HttpClientBuilder mut @no_decompress() -> @ { @auto_decompress = false } /// Cap the decoded body size (bomb guard, D334). A decode that would exceed /// `max_bytes` fails with `Err(BodyTooLarge)` instead of allocating. `< 0` = no cap. #stable(since = "0.1") export fn HttpClientBuilder mut @max_decompressed(max_bytes int) -> @ { @max_decompressed = max_bytes } /// Finalize the client. Fallible for parity with the full builder (tls/proxy); /// CORE construction never fails. #stable(since = "0.1") export fn HttpClientBuilder consume @into_client() -> Result[HttpClient, HttpError] => Ok({ @default_headers, @follow_redirects, @max_redirects, @user_agent, @auto_decompress, @max_decompressed }) // ── verb shortcuts ─────────────────────────────────────────────────────────── /// GET request builder. #stable(since = "0.1") export fn HttpClient @get(url str) -> RequestBuilder => new_rb(@, Get, url) /// POST request builder. #stable(since = "0.1") export fn HttpClient @post(url str) -> RequestBuilder => new_rb(@, Post, url) /// PUT request builder. #stable(since = "0.1") export fn HttpClient @put(url str) -> RequestBuilder => new_rb(@, Put, url) /// DELETE request builder. #stable(since = "0.1") export fn HttpClient @delete(url str) -> RequestBuilder => new_rb(@, Delete, url) /// PATCH request builder. #stable(since = "0.1") export fn HttpClient @patch(url str) -> RequestBuilder => new_rb(@, Patch, url) /// HEAD request builder. #stable(since = "0.1") export fn HttpClient @head(url str) -> RequestBuilder => new_rb(@, Head, url) /// Arbitrary-method request builder (extension methods). #stable(since = "0.1") export fn HttpClient @request(method Method, url str) -> RequestBuilder => new_rb(@, method, url) fn new_rb(c HttpClient, m Method, url str) -> RequestBuilder { // [](str, str).new() в expression-позиции не парсится (tuple-elem vec) — // типизированный локал + пустой литерал. ro q [](str, str) = [] { client: c, method: m, url_input: url, header_ops: []HeaderOp.new(), query: q, body_bytes: []u8.new(), has_body: false, content_type: None } } // ── RequestBuilder ─────────────────────────────────────────────────────────── type HeaderOp value { ro replace bool, ro name str, ro value str } /// Fluent per-request builder. Terminal `@send()` performs the round-trip. #stable(since = "0.1") export type RequestBuilder value { priv client HttpClient priv method Method priv url_input str priv header_ops []HeaderOp priv query [](str, str) priv body_bytes []u8 priv has_body bool priv content_type Option[str] } // [M-http-builders-second-pass] (http-хвост, 2026-07-07): копирующие сеттеры // RequestBuilder → мутирующие свойства (D117 AMEND-2, `mut @x(v) -> @`, auto // self-return D409). Копирующая форма shallow-copy'ила heap-backed // `header_ops`/`query`/`body_bytes`. Беглые цепочки // (`client.get(u).header(..).send()`) корректны после 184 Ф.2. /// Set a header (replace all prior values of this name). #stable(since = "0.1") export fn RequestBuilder mut @header(name str, value str) -> @ { @header_ops.push(HeaderOp { replace: true, name, value }) } /// Append a header (multi-value). #stable(since = "0.1") export fn RequestBuilder mut @append_header(name str, value str) -> @ { @header_ops.push(HeaderOp { replace: false, name, value }) } /// Append query parameters (percent-encoded). #stable(since = "0.1") export fn RequestBuilder mut @query(params [](str, str)) -> @ { for p in params { @query.push(p) } } /// Set a raw byte body (replayable). #stable(since = "0.1") export fn RequestBuilder mut @body(bytes []u8) -> @ { @body_bytes = bytes @has_body = true } /// Set a text body (text/plain; charset=utf-8). #stable(since = "0.1") export fn RequestBuilder mut @text(s str) -> @ { // `body_bytes` is an OWNED `[]u8` field (not `ro`) — `.bytes()` alone is a // zero-copy `ro` view; `.clone()` materialises the owned copy the field needs // (D410: view+.clone() spells the retired `to_bytes()` twin explicitly). @body_bytes = s.bytes().clone() @has_body = true @content_type = Some("text/plain; charset=utf-8") } /// Set a dynamic JSON body (application/json). Typed json[T] gates on Plan 180 /// Ф.4 [M-178-typed-json-needs-180]; the dynamic `JsonValue` form lands now. #stable(since = "0.1") export fn RequestBuilder mut @json(value JsonValue) -> @ { @body_bytes = value.to_str().bytes().clone() @has_body = true @content_type = Some("application/json") } /// Set a form body (application/x-www-form-urlencoded). #stable(since = "0.1") export fn RequestBuilder mut @form(fields [](str, str)) -> @ { @body_bytes = form_encode(fields).bytes().clone() @has_body = true @content_type = Some("application/x-www-form-urlencoded") } /// `Authorization: Bearer <token>`. #stable(since = "0.1") export fn RequestBuilder mut @bearer_auth(token str) -> @ { @header("Authorization", "Bearer ${token}") } /// `Authorization: Basic base64(user:pass)`. #stable(since = "0.1") export fn RequestBuilder mut @basic_auth(user str, pass str) -> @ { ro raw = "${user}:${pass}" ro enc = Base64.encode(raw.bytes()) @header("Authorization", "Basic ${enc}") } /// Materialize a `Request` without sending. #stable(since = "0.1") export fn RequestBuilder @build() -> Result[Request, HttpError] { // `mut` (not `ro`): `apply_query` takes `u` as a `mut` in-out param // (D246 canon (a), see its own comment) — used exactly once, right below. mut url0 = @url_input.to_url()? ro url1 = apply_query(url0, @query) ro headers = build_headers(@client, @header_ops, @content_type)? Ok(Request.new(@method, url1, headers, @body_bytes)) } /// Send the request (round-trip through the `Http` seam), following redirects. /// Returns a must-consume `Response` (4xx/5xx included — use `error_for_status`). #stable(since = "0.1") // send = ЭФФЕКТНОЕ ДЕЙСТВИЕ (Http round-trip), не конверсия владения: ось §1а // into_* покрывает конверсии, не сетевые действия (приёмка волны №79; // кандидат — эффект-исключение в сам линт W_CONSUME_NAKED_NAME). // nova:allow W_CONSUME_NAKED_NAME -- effectful action, not a conversion export fn RequestBuilder consume @send() Http -> Result[Response, HttpError] { // `mut` (not `ro`): `apply_query` takes `u` as a `mut` in-out param // (D246 canon (a)) — used exactly once, right below. mut url0 = @url_input.to_url()? // `mut` (not `ro`): `run_request` takes `url1`/`headers` as `mut` in-out // params (D246 canon (a), see that fn's own comment) — this binding is // never reused after the tail call below. mut url1 = apply_query(url0, @query) ro scheme = url1.scheme if scheme != "http" && scheme != "https" { return Err(HttpError.new(InvalidUrl).with_url(url1)) } mut headers = build_headers(@client, @header_ops, @content_type)? run_request(@method, url1, headers, @body_bytes, @client.cfg_follow(), @client.cfg_max(), @client.cfg_auto_decompress(), @client.cfg_max_decompressed()) } // ── convenience (script / one-shot) — uses a fresh default client ──────────── // Prod code should use an explicit HttpClient (no hidden global state, §3 Q15). /// One-shot GET (script convenience). #stable(since = "0.1") export fn get(url str) Http -> Result[Response, HttpError] => HttpClient.new().get(url).send() /// One-shot HEAD. #stable(since = "0.1") export fn head(url str) Http -> Result[Response, HttpError] => HttpClient.new().head(url).send() /// One-shot DELETE. #stable(since = "0.1") export fn delete(url str) Http -> Result[Response, HttpError] => HttpClient.new().delete(url).send() /// One-shot POST with a byte body. #stable(since = "0.1") export fn post(url str, body []u8) Http -> Result[Response, HttpError] => HttpClient.new().post(url).body(body).send() /// One-shot PUT with a byte body. #stable(since = "0.1") export fn put(url str, body []u8) Http -> Result[Response, HttpError] => HttpClient.new().put(url).body(body).send() /// One-shot PATCH with a byte body. #stable(since = "0.1") export fn patch(url str, body []u8) Http -> Result[Response, HttpError] => HttpClient.new().patch(url).body(body).send() // ── request execution + redirect loop ─────────────────────────────────────── // Thin wrapper over the `Http.send` seam op — gives codegen a concrete // monomorphized `Result[str, HttpError]` (mirrors how net wraps its effect ops in // TcpStream methods; calling the raw op inline mis-typed the Err payload). fn http_seam_send(host str, port int, secure bool, request str) Http -> Result[str, HttpError] { Http.send(host, port, secure, request) } // `method`/`url`/`headers`/`body` are all `mut` params (D246 canon (a)): // genuinely in-out — the redirect loop below reassigns each of them // (`method = Get`, `body = []u8.new()`, `headers = drop_body_headers(...)`, // `url = next`) as it walks the chain, not an independent-copy case. The one // call site (`@send` below) never reuses its own `url1`/`headers` bindings // after this call. fn run_request(mut method Method, mut url Url, mut headers HeaderMap, mut body []u8, follow bool, max int, auto_decompress bool, max_decompressed int) Http -> Result[Response, HttpError] { mut remaining = max mut i = 0 while i <= max { i += 1 ro (scheme, host, port) = url.origin() ro secure = scheme == "https" ro target = request_target(url) ro host_header = host_header_of(url) ro req = serialize_request(method.str(), target, host_header, headers, body) ro raw = http_seam_send(host, port, secure, req)? mut parsed = parse_response(raw)? ro st = parsed.status ro code = st.code() if follow && st.is_redirect() && code != 304 { match location_of(parsed.headers) { Some(loc) => { if remaining <= 0 { return Err(HttpError.new(TooManyRedirects(max)).with_url(url)) } ro next = resolve_url(url, loc) ro (ns, nh, np) = next.origin() ro cross = ns != scheme || nh != host || np != port if code == 303 || ((code == 301 || code == 302) && !method_is_get_head(method)) { method = Get body = []u8.new() headers = drop_body_headers(headers) } if cross { headers = strip_sensitive(headers) } url = next remaining -= 1 } None => { return finalize_response(parsed.version, st, parsed.headers, parsed.body, auto_decompress, max_decompressed) } } } else { return finalize_response(parsed.version, st, parsed.headers, parsed.body, auto_decompress, max_decompressed) } } Err(HttpError.new(TooManyRedirects(max)).with_url(url)) } // ── transparent Content-Encoding decode (gzip/deflate) ─────────────────────── // Build the terminal Response, transparently decoding a gzip/deflate body when // auto-decompress is on. On success the Content-Encoding header is dropped and // Content-Length rewritten to the decoded length (so the returned headers describe // the body the caller actually sees). `br` (brotli) is decoded via Plan 179 Ф.2 // (libbrotlidec C-FFI) [M-178-autodecompress-br]; on a build without the vendored // brotli lib the decode returns UnsupportedMethod → surfaced as a Protocol error. // `headers` is `mut` (D246 canon (a)): passed into `rewrite_encoding_ // headers`'s own `mut` param on the decode branches below; both callers // (`run_request` above) pass `parsed.headers` (a field access, not a bound // identifier), so this doesn't constrain them. fn finalize_response(ver Version, st StatusCode, mut headers HeaderMap, body []u8, auto_decompress bool, max_decompressed int) -> Result[Response, HttpError] { if !auto_decompress { return Ok(Response.new(ver, st, headers, body)) } ro enc = content_encoding_lower(headers) if enc.byte_len() == 0 { return Ok(Response.new(ver, st, headers, body)) } if enc == "gzip" || enc == "x-gzip" { ro decoded = decode_or_err(gzip_decode(body, max_decompressed))? Ok(Response.new(ver, st, rewrite_encoding_headers(headers, decoded.len()), decoded)) } else if enc == "deflate" { // HTTP "deflate" is nominally zlib (RFC 9110); tolerate raw-DEFLATE senders. ro decoded = match zlib_decode(body, max_decompressed) { Ok(b) => b Err(e) => if e.is_bomb() { return Err(HttpError.body_too_large()) } else { decode_or_err(inflate(body, max_decompressed))? } } Ok(Response.new(ver, st, rewrite_encoding_headers(headers, decoded.len()), decoded)) } else if enc == "br" { // brotli (RFC 7932) via Plan 179 Ф.2 [M-178-autodecompress-br]. ro decoded = decode_or_err(brotli_decode(body, max_decompressed))? Ok(Response.new(ver, st, rewrite_encoding_headers(headers, decoded.len()), decoded)) } else { // identity / unknown → leave the body (and its Content-Encoding) as-is. Ok(Response.new(ver, st, headers, body)) } } // Map a compress decode Result to an http Result: bomb (cap exceeded, D334) → // BodyTooLarge; any other decode failure → Protocol + typed Compress source. fn decode_or_err(r Result[[]u8, CompressError]) -> Result[[]u8, HttpError] { match r { Ok(b) => Ok(b) Err(e) => if e.is_bomb() { Err(HttpError.body_too_large()) } else { Err(HttpError.from_compress(e)) } } } // The (trimmed, lowercased) Content-Encoding token, or "" if absent/undecodable. fn content_encoding_lower(h HeaderMap) -> str { match h.get("content-encoding") { // HeaderValue already trims OWS at parse time — just normalise case. Some(hv) => match hv.to_str() { Ok(s) => s.to_ascii_lower() Err(_) => "" } None => "" } } // Drop Content-Encoding + Transfer-Encoding and set Content-Length to the decoded // size — the returned headers now describe the decoded body (Go/reqwest behaviour). // `h` is `mut` (D246 canon (a)): every call site (`finalize_response` below) // hands its own `mut headers` param through exactly once, no independent // copy needed. fn rewrite_encoding_headers(mut h HeaderMap, decoded_len int) -> HeaderMap { ro _ = h.remove("content-encoding") ro _ = h.remove("transfer-encoding") // Статически валидное имя заголовка — insert отказать не может. ro _ = h.insert("Content-Length", decoded_len.to_str()) h } fn build_headers(client HttpClient, ops []HeaderOp, content_type Option[str]) -> Result[HeaderMap, HttpError] { mut hm = HeaderMap.new() for kv in client.cfg_defaults() { hm.insert(kv.name, kv.value)? } ro ua = client.cfg_ua() if !hm.contains("user-agent") && ua.byte_len() > 0 { hm.insert("User-Agent", ua)? } // Advertise the codecs we can transparently decode (gzip/deflate/br — brotli // via Plan 179 Ф.2, [M-178-autodecompress-br]). A caller-set Accept-Encoding // wins. Skipped entirely when auto-decompress is off (caller owns encoding). if client.cfg_auto_decompress() && !hm.contains("accept-encoding") { hm.insert("Accept-Encoding", "gzip, deflate, br")? } match content_type { Some(ct) => { hm.insert("Content-Type", ct)?; () } None => () } for op in ops { ro r = if op.replace { hm.insert(op.name, op.value) } else { hm.append(op.name, op.value) } r? } Ok(hm) } // `u` is `mut` (D246 canon (a)): the `params.len() == 0` early return hands // it straight back unchanged (identity passthrough — bare-generic-return // shape, `str`/value-record not scalar-exempt); both call sites (`@build`/ // `@send` below) pass their own single-use `url0` binding, made `mut` there // for the same reason. fn apply_query(mut u Url, params [](str, str)) -> Url { if params.len() == 0 { return u } consume sb = StringBuilder.new() sb.cap(64) mut first = true match u.query { Some(q) => { sb.append(q) first = q.byte_len() == 0 } None => () } for (k, v) in params { if !first { sb.append("&") } sb.append(Url.encode_query(k)) sb.append("=") sb.append(Url.encode_query(v)) first = false } { scheme: u.scheme, user: u.user, password: u.password, host: u.host, port: u.port, path: u.path, query: Some(sb.into_str()), fragment: u.fragment } } fn request_target(u Url) -> str { ro p = if u.path.byte_len() == 0 { "/" } else { u.path } match u.query { Some(q) => "${p}?${q}" None => p } } fn host_header_of(u Url) -> str { match u.host { Some(h) => { ro hb = if h.contains(":") { "[${h}]" } else { h } match u.port { Some(p) => "${hb}:${p}" None => hb } } None => "" } } fn location_of(h HeaderMap) -> Option[str] { match h.get("location") { Some(hv) => match hv.to_str() { Ok(s) => Some(s), Err(_) => None } None => None } } fn method_is_get_head(m Method) -> bool { match m { Get | Head => true _ => false } } // `h` is `mut` (D246 canon (a)): the one call site (`run_request`'s redirect // loop above) passes its own `mut headers` param through exactly once. fn strip_sensitive(mut h HeaderMap) -> HeaderMap { ro _ = h.remove("authorization") ro _ = h.remove("cookie") h } // Drop body-framing headers when a redirect GET-ifies the request. Same // rationale as `strip_sensitive` above. fn drop_body_headers(mut h HeaderMap) -> HeaderMap { ro _ = h.remove("content-length") ro _ = h.remove("content-type") ro _ = h.remove("transfer-encoding") h } // Resolve a Location value against the current URL (absolute / absolute-path / // relative). Unparseable → falls back to the base URL (redirect stops there). fn resolve_url(base Url, loc str) -> Url { if loc.starts_with("http://") || loc.starts_with("https://") { match loc.to_url() { Ok(u) => u, Err(_) => base } } else if loc.starts_with("//") { match "${base.scheme}:${loc}".to_url() { Ok(u) => u, Err(_) => base } } else if loc.starts_with("/") { url_with_target(base, loc) } else { url_with_target(base, merge_rel_path(base.path, loc)) } } fn url_with_target(base Url, target str) -> Url { consume sb = StringBuilder.new() sb.cap(64) sb.append(base.scheme) sb.append("://") match base.host { Some(h) => { if h.contains(":") { sb.append("[${h}]") } else { sb.append(h) } } None => () } match base.port { Some(p) => { sb.append(":"); sb.append(p.to_str()); () } None => () } if !target.starts_with("/") { sb.append("/") } sb.append(target) match sb.into_str().to_url() { Ok(u) => u, Err(_) => base } } fn merge_rel_path(base_path str, rel str) -> str { ro dir = match last_slash(base_path) { Some(i) => base_path[0..i + 1] None => "/" } "${dir}${rel}" } fn last_slash(s str) -> Option[int] { ro b = s.bytes() mut i = b.len() - 1 mut found = -1 mut scanning = true while scanning && i >= 0 { if (b[i] as int) == 0x2F { found = i; scanning = false } i -= 1 } if found >= 0 { Some(found) } else { None } } fn form_encode(fields [](str, str)) -> str { consume sb = StringBuilder.new() sb.cap(64) mut first = true for (k, v) in fields { if !first { sb.append("&") } sb.append(Url.encode_query(k)) sb.append("=") sb.append(Url.encode_query(v)) first = false } sb.into_str() } // ── dynamic JSON body (Response extension; keeps the json dep out of core) ──── /// Consume the response body and parse it as dynamic JSON (`JsonValue`). For a /// typed record `T`, use `http.serdejson.json_decode_body[T](resp.into_bytes()!!)`. #stable(since = "0.1") export fn Response consume @into_json() -> Result[JsonValue, HttpError] { ro text = @into_text()? match Json.parse(text) { Ok(v) => Ok(v) Err(_) => Err(HttpError.protocol_error("response body is not valid JSON")) } }