#config —— 请求配置

    Config 描述「一个请求长什么样」:地址、方法、超时、请求头、查询参数、请求体、认证、代理、进度回调、取消句柄。实例默认值(Client 里那份)与本次请求的配置都是它,两者的合并规则也在本包(merge_config)。

    本包是纯逻辑包:不依赖 async 运行时,合并语义与请求体字节都能用同步测试钉死。依赖 headers(头集合)与 url(复用 query 序列化器),不反向依赖本模块的任何包。

    import { "q2316367743/moonhttp/config", }

    #怎么造一份配置

    ///|
    let config = Config::new("/users") // 带 url 起步

    ///|
    let defaults = Config::default() // 从零起步
    .with_base_url("https://api.example.com")
    .with_timeout(5_000)

    Config 的请求体是私有字段,所以包外写不出记录字面量 / 记录展开——构造只能走上面两个入口加 with_*。这样「请求体」与它配套的 Content-Type 不会被拆散,往结构体加字段忘了配合并策略也会变成编译错误。

    方法字段叫 http_method 而不是 method(method 是 MoonBit 保留字),构建器仍叫 with_method。

    #构建器:23 个 with_*

    地址与方法

    方法说明
    with_url(url)请求地址:相对地址与 base_url 拼接,绝对地址直连
    with_base_url(url)基地址,通常写在实例默认值里
    with_method(Method)请求方法,缺省回退到 GET
    with_allow_absolute_urls(bool)是否允许绝对地址直连(默认 true)

    超时与重定向

    方法说明
    with_timeout(ms)超时毫秒数,0 表示不超时(默认)
    with_max_redirects(n)最多跟几跳重定向,默认 5,0 表示不跟随

    请求头

    方法说明
    with_header(name, value)本次请求的头
    with_headers(Headers)一次性给一组头
    with_common_header(name, value)公共头:该实例的所有方法都带上
    with_method_header(Method, name, value)按方法区分的头(如只给 Post 加 Content-Type)

    三层优先级:common_headers < method_headers < 请求级 headers,逐层覆盖(头名大小写不敏感)。

    查询参数

    方法说明
    with_params(Json)查询参数,数组与嵌套对象按括号约定展开
    with_params_serializer(fn(Json) -> String)整体替换「params → query 文本」这一步(只作用于 URL 的 query)

    请求体

    方法说明自动补的 Content-Type
    with_data_from_str(String)原样 UTF-8 字节不补
    with_data_from_json(Json)stringify() 后的 JSON 文本application/json
    with_data_from_urlencoded(Json)a=1&b=2application/x-www-form-urlencoded
    with_data_from_form(FormData)multipart 正文multipart/form-data; boundary=...

    补头一律是 set_if_absent:你自己设了同名头就一个字节都不改。

    认证与代理

    方法说明
    with_auth(username, password)Basic 认证,落成 Authorization 头
    with_proxy(host, port?, protocol?, username?, password?)代理;凭据只落在建隧道的 CONNECT 上,不发给目标服务器

    进度与取消

    方法说明
    with_on_upload_progress(cb)上传进度回调(同步、不抛错)
    with_on_download_progress(cb)下载进度回调
    with_cancel_token(CancelToken)绑定取消句柄

    响应处理

    方法说明
    with_response_encoding(ResponseEncoding)响应体按什么解码,默认 Utf8
    with_validate_status(fn(Int) -> Bool)状态码校验规则,默认「2xx 算成功」

    #Config 的其它方法

    方法说明
    Config::new(url) / Config::default()构造
    serialize_body()请求体 → 字节 + 建议的 Content-Type(SerializedBody)
    next_redirect(location, status, method?)算重定向的下一跳配置(方法、请求体、凭据、Host 的改写规则)
    to_string() / output(logger) / to_repr()渲染(Show / Debug)

    #配套类型

    类型说明
    MethodGet / Head / Post / Put / Delete / Connect / Options / Trace / Patch,另有 Method::parse(view)
    ResponseEncodingUtf8(默认)/ Latin1 / Ascii / Utf16le
    Auth{ username?, password? },Basic 凭据
    Proxy / ProxyProtocol{ protocol?, host?, port?, auth? };Proxy::is_usable() 判断「配了代理但没给 host」
    FormData / FormValue / FormFilenew() / append_text(name, value) / append_file(name, filename, bytes, content_type?) / entries() / length()
    CancelTokennew() / cancel(message?) / is_cancelled() / reason() / attach(cb) / detach(id)
    ProgressEvent / ProgressCallback{ loaded, total? } + progress()(total 未知或为 0 时给 None)
    SerializedBody{ bytes?, content_type? }

    #包级函数

    函数说明
    defaults()内置默认值:timeout = 0(不超时)、max_redirects = 5、response_encoding = Utf8、validate_status = 2xx、Accept: application/json, text/plain, */*、allow_absolute_urls = true
    merge_config(默认值, 请求级)请求级配置合并在实例默认值之上
    flatten_headers(公共头?, 按方法的头?, 请求级头?, 方法)三层头拍平成一份
    merge_json(a, b) / merge_json_option(a, b)JSON 深合并(params 用它)

    #合并策略

    字段各归属一档,None 一律表示「未提供」→ 回退,而不是覆盖:

    字段策略
    url / http_method / 请求体只取请求级
    base_url / timeout / max_redirects / response_encoding / 进度回调 / cancel_token请求级优先,缺省回退实例默认值
    params / headers / auth / proxy逐层深合并(数组整体替换,函数没法合并所以整体接管)
    validate_status / params_serializer请求级提供即整体接管

    合并、请求体字节布局与重定向改写规则的完整说明见 docs/02-config-merge.md 与 docs/07-request-body.md。

    Headers

    把 headers 包的 Headers 引入本包作用域,同时作为 config 公开 API 的一部分 再导出——Config 的字段类型出现在签名里,使用者不该被迫再 import 一个包。

    ProgressCallback

    type ProgressCallback = (ProgressEvent) -> Unit

    进度回调。

    noraise:回调是在请求发送 / 响应读取的中途被调用的,它抛出的错误没有 合理的归属方(既不属于这次请求的传输错误,也不该让整条请求失败), 所以类型上就要求它不抛。想在回调里记日志、推 UI 状态都没问题。

    回调是同步执行的:它占用请求本身的时间预算(上传回调落在 timeout 覆盖的发送阶段里),所以别在里面做耗时的事。

    Auth

    pub(all) struct Auth {
    username : String?
    password : String?
    } derive(Default, Eq,
    Debug
    )

    Authorization: Basic ... 用的凭据。

    两个字段都是 Option,所以它的合并是逐字段的深合并: 默认值只给了 username、请求只给了 password,合并后两者都在。 这正是 axios mergeDeepProperties 对普通对象(plain object)的处理方式。

    Auth::default

    fn Auth::default() -> Auth

    Auth::equal

    fn Auth::equal(Auth, Auth) -> Bool

    Auth::not_equal

    fn Auth::not_equal(x : Auth, y : Auth) -> Bool

    Auth::to_repr

    Auth::to_string

    fn Auth::to_string(self : Auth) -> String

    渲染认证信息:用户名照常显示,密码一律替换成 <redacted>。

    配置经常整体进日志或断言失败信息,明文打印密码会把凭据泄漏到 日志文件与 CI 输出里,所以这里主动脱敏。

    CancelToken

    pub struct CancelToken {
    // private fields
    }

    取消句柄:一个可以传给任意多次请求的「取消信号」。

    用法与 axios 的 CancelToken 一致,但只有一个对象——axios 把 「传出去的 token」与「调用方手里的 source」分成两个,是为了避免拿到 token 的一方误调用 cancel;在 MoonBit 里这层防护换来的是每次使用都多一个类型, 而取消这件事本来就需要某个持有者来触发,二分的收益抵不上成本。

    几条刻意的语义:
    • 一次性:取消之后永远是取消状态,不能复用(与 axios 一致); 需要「一次请求一个信号」就每次 CancelToken::new()。
    • 可共享:同一个 token 可以挂到多个请求上,cancel 一次全部生效; 取消发生之后才挂上来的请求会立刻失败,不会「取消晚了一步就照常发出」。
    • 幂等:重复 cancel 只生效第一次,第二次连理由都不会改。

    cancel 是同步函数且不抛错,所以可以从任何地方调用——包括进度回调里 (它是 noraise 的,得不到通知就只能靠这种同步调用)。落点见 docs/12-cancellation.md 的「取消的落点」。

    CancelToken::attach

    fn CancelToken::attach(self : CancelToken, handle : () -> Unit) -> Int

    登记一个「取消时要做的事」,返回注销用的票号。

    这是给传输层用的内部机制,不是使用者 API:一次请求在开始一段可被取消的 I/O 之前把自己的中断手段挂上来,结束时注销。使用者只该调 cancel。

    已经取消的 token 会立刻调用 handle,返回 NO_TICKET。这条不是顺手 补的容错,而是取消语义的兜底:cancel 只通知得到当时登记过的句柄,而请求 可能在两段 I/O 之间(重定向的两跳之间就是一个真实的时间窗)才去登记, 这时它必须马上断掉,而不是照常把这跳发出去。

    CancelToken::cancel

    fn CancelToken::cancel(self : CancelToken, message? : String) -> Unit

    取消。message 会原样成为这次请求失败时的错误描述(HttpError::message); 不传就用库的默认文案(见 docs/04-errors.md)。

    同步、不抛错、幂等:第一次之后的所有调用都是空操作。触发的回调按登记顺序 同步执行,所以调用 cancel 的那条协程会等所有清理动作做完 (例如关闭响应体连接)才继续。

    CancelToken::detach

    fn CancelToken::detach(self : CancelToken, ticket : Int) -> Unit

    注销一次登记。票号越界一律忽略——这既覆盖「重复注销」,也覆盖取消链路里 的那次自注销(cancel 已经把数组清空,旧票号必然越界)。

    CancelToken::is_cancelled

    fn CancelToken::is_cancelled(self : CancelToken) -> Bool

    是否已取消。

    CancelToken::new

    新建一个尚未取消的 token。

    CancelToken::reason

    fn CancelToken::reason(self : CancelToken) -> String?

    取消时传入的 message;没取消、或取消时没给 message 时是 None。

    想区分「没取消」与「取消了但没给理由」用 is_cancelled() 配它, 别用 reason() 的 None 当判断依据。

    Config

    pub(all) struct Config {
    url : String?
    http_method : Method?
    base_url : String?
    timeout : Int?
    max_redirects : Int?
    response_encoding : ResponseEncoding?
    params : Json?
    params_serializer : (Json) -> String?
    on_upload_progress : (ProgressEvent) -> Unit?
    on_download_progress : (ProgressEvent) -> Unit?
    cancel_token : CancelToken?
    auth : Auth?
    proxy : Proxy?
    headers :
    Headers
    ?
    common_headers :
    Headers
    ?
    method_headers : Map[Method,
    Headers
    ]?
    validate_status : (Int) -> Bool?
    allow_absolute_urls : Bool?
    // private fields
    } derive(Default)

    一次请求的完整配置。它同时充当 axios 里的实例 defaults 与请求级 config, 两者的区别只在合并时「谁覆盖谁」,形状完全一致。

    每个字段都是 Option,None 表示「这一项没有提供」。 用 Option 而不是给每项塞一个哨兵默认值,是为了让「未提供」与 「显式提供了一个恰好等于默认值的值」在合并时能被区分开—— 这是复刻 axios mergeConfig 语义的前提(那里用 JS 的 undefined 表达同一件事)。

    字段按合并策略分组,与 merge 包里的实现一一对应:
    • 只取请求级(axios valueFromConfig2):url / http_method / data
    • 请求级优先、否则回退默认(defaultToConfig2): base_url / timeout / max_redirects / response_encoding / params_serializer / on_upload_progress / on_download_progress / cancel_token
    • 深合并(mergeDeepProperties): params / auth / proxy / headers / common_headers / method_headers / allow_absolute_urls
    • 请求级存在即生效(mergeDirectKeys):validate_status

    字段名用 http_method 而不是 axios 的 method:method 在 MoonBit 里 是保留字,直接用作字段名会触发告警,也会让使用者写记录字面量时踩到。 构建器仍保留短名 with_method,因为参数类型 Method 已经说明了语境。

    因为 validate_status / params_serializer / 两个进度回调都是函数类型字段, 本类型不能 derive(Debug) 也不能 derive(Eq),两者都是手写实现 (Debug 把函数渲染成 <fn>,需要比较配置时请逐字段比较)。
    impl Show for Config
    impl Debug for Config

    Config::default

    fn Config::default() -> Config

    Config::new

    fn Config::new(url : String) -> Config

    以目标地址为起点构造一份请求配置,等价于 Config::default().with_url(url)。

    url 是每次请求都必须提供的字段,把它做成构造参数,最常见的调用就能从 Config::default().with_url("/users") 缩短成 Config::new("/users")。

    之所以做成构造器而不是给 Client::request 加一个 request(url, config?) 重载:MoonBit 不允许同一类型上有同名方法(会报 The method request for type Client has been defined), 所以缩短调用点只能在 Config 这一侧做。

    注意这里设置的是本次请求的目标地址(Config.url),不是实例的基础地址。 给实例设置基础地址请用 Config::default().with_base_url(...):两者语义不同, url 在合并时走「只取请求级」策略,放在实例默认值里不会生效。

    Config::next_redirect

    fn Config::next_redirect(self : Config, current_url : String, status : Int, location : String?) -> Config?

    这次响应要不要跟随?要跟就返回下一跳的配置,不要跟返回 None。

    current_url 是产出这份响应的完整地址(PreparedRequest.url,已经含 base_url 拼接结果与序列化后的 query):相对 Location 必须相对实际发出去的 地址解析,而不是配置里那个没拼过的 url。

    返回 None 的三种情况:
    • 状态码不在 3xx:正常响应,原样交出去;
    • 没有 Location 头,或它的值是空白:follow-redirects 里 !location 直接走 「不是重定向」这条分支,于是 3xx 落到 validate_status 手里由调用方定夺;
    • Location 解析不出绝对地址(基地址不是绝对地址):当成不可跟随。

    下一跳的配置里,这几项被改写:
    • url 换成解析出的绝对地址(不含 fragment),base_url 与 params 清空 ——query 已经在那个地址里,再参与一次就是二次拼接 / 二次追加;
    • http_method 按状态码决定(见 rewrites_to_get),改写成 GET 时连 data 与 content-* 头一起丢掉;
    • 凭据(Authorization / Proxy-Authorization / Cookie 与 auth 字段) 在跨 host 或协议降级时丢掉(见 keeps_credentials);
    • 用户显式设的 Host 头一律丢掉,让传输层按新地址重新生成。

    其余字段(timeout / max_redirects / validate_status / 各层头…)原样保留。

    Config::output

    fn Config::output(self : Config, logger : &Logger) -> Unit

    Config::serialize_body

    fn Config::serialize_body(self : Config) -> SerializedBody

    把请求体序列化成待发送字节与建议的 Content-Type;没有 body 时两者都是 None。

    为什么序列化在 config 包而不是拼请求的地方:data 私有,只有本包能 match Body; 而「body 怎么变成字节」本来就是请求体类型自己的事(与 url/ 包负责百分号编码同理)。 拼请求的那一层只负责把这里给出的 content_type 用 set_if_absent 落到头上。

    Config::to_repr

    Config::to_string

    fn Config::to_string(self : Config) -> String

    紧凑渲染,只打印被显式提供的字段。

    None 表示「未提供」,把它打印出来只是噪音,也会让合并前后的对比失真。 函数字段(validate_status / params_serializer)渲染成 <fn>。

    Config::with_allow_absolute_urls

    fn Config::with_allow_absolute_urls(self : Config, allow_absolute_urls : Bool) -> Config

    设置 url 为绝对地址时是否仍然直接使用。 设为 false 时,即使 url 是绝对地址也会被拼到 base_url 后面。

    Config::with_auth

    fn Config::with_auth(self : Config, username : String, password : String) -> Config

    设置 Basic 认证凭据。

    Config::with_base_url

    fn Config::with_base_url(self : Config, base_url : String) -> Config

    设置基础地址。

    Config::with_cancel_token

    fn Config::with_cancel_token(self : Config, cancel_token : CancelToken) -> Config

    给这次请求挂上取消句柄,对应 axios 的 cancelToken / signal。

    与 axios 一样,token 可以来自请求级配置,也可以放在实例默认值上 (后者是「这个实例发出的请求共用一个取消信号」的用法)。

    let token = CancelToken::new()
    let config = Config::new("/reports/big.csv")
    .with_cancel_token(token)
    // 另一处(另一个协程、或某个 UI 回调里):
    token.cancel(message="用户点了停止")

    Config::with_common_header

    fn Config::with_common_header(self : Config, name : StringView, value : String) -> Config

    追加一条 common 层头(默认值里最低优先级的一层)。

    Config::with_data_from_form

    fn Config::with_data_from_form(self : Config, form : FormData) -> Config

    以表单为请求体:编码成 multipart/form-data(文本字段与文件都走这条), 并补 Content-Type: multipart/form-data; boundary=...,boundary 每次序列化现生成。

    文件以「字节 + 文件名」传入(见 FormData::append_file):库不读盘, 从内存造字节或自己读文件都由调用方决定。

    Config::with_data_from_json

    fn Config::with_data_from_json(self : Config, body : Json) -> Config

    以 JSON 为请求体:自动 stringify() 成 JSON 文本,并补 Content-Type: application/json。

    注意 with_data_from_json(Json::String("hi")) 发出去的是带引号的 "hi"—— 它是 JSON 字符串字面量,与 with_data_from_str("hi") 发出的裸 hi 不是一回事, 后者也不会补 JSON 头。

    Config::with_data_from_str

    fn Config::with_data_from_str(self : Config, body : String) -> Config

    以原样文本为请求体:不做任何处理,按 UTF-8 编码后发送,也不推断 Content-Type。

    适合纯文本,以及调用方自己序列化好的载体——例如手拼的 urlencoded 表单:

    @moonhttp.Config::new("/login")
    .with_data_from_str("user=alice&password=s3cret")
    .with_header("Content-Type", "application/x-www-form-urlencoded")

    想要「发出去的是合法 JSON」请用 with_data_from_json:它会把值序列化并补头。

    Config::with_data_from_urlencoded

    fn Config::with_data_from_urlencoded(self : Config, body : Json) -> Config

    以 URL 编码表单(application/x-www-form-urlencoded)为请求体: 键值对编码成 a=1&b=2 后发送,并补 Content-Type: application/x-www-form-urlencoded。

    与 params 用的是同一套序列化规则(axios 的 toFormData 默认选项), 所以同一份 Json 放 query 还是放 body 只差一个方法名: {"a": 1} → a=1,数组与嵌套对象按 tags%5B%5D=a&tags%5B%5D=b、 filter%5Bstatus%5D=1 展开(方括号会被百分号编码,这是 axios 的真实输出), 值为 null 的键一律跳过,空格写成 +。

    顶层必须是对象:传数组或标量等于一份空表单(与 axios 的 paramsSerializer 一致)。 后端要的是别的约定(例如列表用重复平键 tags=a&tags=b)时, 用 with_data_from_str 自己拼 + 自己设 Content-Type。

    文件不行:urlencoded 里没有承载二进制的位置,带文件请用 with_data_from_form。

    Config::with_header

    fn Config::with_header(self : Config, name : StringView, value : String) -> Config

    追加一条请求级头(优先级最高的一层)。同名头会被覆盖。

    Config::with_headers

    fn Config::with_headers(self : Config, headers :
    Headers
    ) -> Config

    整体替换请求级头。需要一次设置多条时比连续调用 with_header 更清晰。

    Config::with_max_redirects

    fn Config::with_max_redirects(self : Config, max_redirects : Int) -> Config

    设置最多跟随几次重定向。

    传 0 表示不跟随(3xx 直接交给 validate_status 判定,默认规则下会判成失败); 1 表示只跟一跳。上限按「跳数」计,超过就抛 ErrorCode::TooManyRedirects。

    Config::with_method

    fn Config::with_method(self : Config, meth : Method) -> Config

    设置请求方法。字段名是 http_method,这里保留 axios 风格的短名。

    Config::with_method_header

    fn Config::with_method_header(self : Config, meth : Method, name : StringView, value : String) -> Config

    追加一条按方法分层的头,只在用该 meth 发请求时生效。

    Config::with_on_download_progress

    fn Config::with_on_download_progress(self : Config, on_download_progress : (ProgressEvent) -> Unit) -> Config

    设置下载进度回调,对应 axios 的 onDownloadProgress。

    只由**库执行的「读全量」**触发:Client::request 与 StreamResponse::read_all。按块读(read_some / read_until)与 SSE 不触发——那两条路由调用方自己驱动,进度自己统计即可。

    Config::with_on_upload_progress

    fn Config::with_on_upload_progress(self : Config, on_upload_progress : (ProgressEvent) -> Unit) -> Config

    设置上传进度回调,对应 axios 的 onUploadProgress。

    请求体写入连接时按块调用:先写一段、刷出去,再报一次进度。 触发时机、粒度与 total 的口径见 docs/10-progress.md。

    Config::new("/upload")
    .with_data_from_form(form)
    .with_on_upload_progress(fn(event) {
    println("\{event.loaded} / \{event.total.unwrap_or(0)}")
    })

    Config::with_params

    fn Config::with_params(self : Config, params : Json) -> Config

    设置查询参数,通常直接传一个字面量 JSON 对象: with_params({ "page": 1, "tags": ["a", "b"] })

    Config::with_params_serializer

    fn Config::with_params_serializer(self : Config, params_serializer : (Json) -> String) -> Config

    自定义 query 序列化器,对应 axios 的 paramsSerializer(函数形式)。

    传入的函数整体替换内置的序列化规则,返回值就是 query 本体。适合内置约定不合用的 后端——例如列表要写成重复平键 tags=a&tags=b,而不是 axios 那套 tags%5B%5D=a:

    Config::new("/search")
    .with_params({ "tags": ["a", "b"] })
    .with_params_serializer(fn(_params) { "tags=a&tags=b" })

    只管 URL 的 query,不管请求体(理由见 Config::params_serializer 的字段说明); 合并时请求级整体替换实例默认值。

    Config::with_proxy

    fn Config::with_proxy(self : Config, host : String, port? : Int, protocol? : ProxyProtocol, username? : String, password? : String) -> Config

    设置代理服务器(对应 axios 的 proxy 对象)。

    host 必填,其余可选——正因为只有它必填,正常写法下不会出现 「有代理却没地址」的配置:

    Config::default().with_proxy("127.0.0.1", port=9000)
    Config::default().with_proxy(
    "proxy.corp", port=8443, protocol=ProxyProtocol::Https,
    username="mikeymike", password="rapunz3l",
    )

    username / password 只用于建立隧道时的 CONNECT 请求(Basic 认证), 不会发给目标服务器;两者都不给则是匿名代理。只给其中一个时,另一半按空串 参与编码——与 with_auth 的既有行为一致。

    作用范围:所有请求都经由代理(含 http 目标,本项目的底层统一走 CONNECT 隧道), 跟随重定向时每一跳各建一次隧道。只支持 http/https 代理,不做 SOCKS; 不读 http_proxy / no_proxy 环境变量,也不提供按请求关闭代理的开关 ——细节与理由见 docs/09-proxy.md。

    Config::with_response_encoding

    fn Config::with_response_encoding(self : Config, response_encoding : ResponseEncoding) -> Config

    设置响应体字节的解码方式(默认 Utf8)。

    只影响「读全量」的 Client::request 拿到的 Response::text()(以及 json() 里那一步解码):stream 交原始字节、sse 按规范固定 UTF-8, 都不读这个字段。

    Config::with_timeout

    fn Config::with_timeout(self : Config, timeout : Int) -> Config

    设置超时(毫秒)。传 0 表示不限时。

    Config::with_url

    fn Config::with_url(self : Config, url : String) -> Config

    链式构建器,例如:

    let config = Config::default()
    .with_base_url("https://api.example.com")
    .with_timeout(5000)

    方法名统一加 with_ 前缀,避免和同名字段访问冲突: config.url 读字段,config.with_url(...) 才是返回值的新配置。

    每个构建器都返回新实例(immutable 风格),所以可以安全地把一个 「模板配置」派生出多个变体,互不影响。

    例外:with_proxy 与 Proxy 类型同放在 proxy.mbt,便于「代理相关的一切」 集中在一处(文件长度限制见 AGENTS.md RL-04)。

    Config::with_validate_status

    fn Config::with_validate_status(self : Config, validate_status : (Int) -> Bool) -> Config

    自定义状态码校验规则。传一个恒为 true 的函数即可关闭校验。

    FormData

    pub struct FormData {
    // private fields
    }

    表单请求体:有序的「字段名 → 值」列表。

    同名可以出现多次(多个值、多个文件都会各自成为一项),顺序就是 append_* 的顺序, 服务端通常按顺序取第一个同名项,所以「同名字段覆盖」这类语义不在这里做。

    与 Headers 一样是值语义:append_* 返回新实例, 拿一份表单当模板派生出多份,互相之间不会被改坏。
    impl Show for FormData
    impl Debug for FormData

    FormData::append_file

    fn FormData::append_file(self : FormData, name : StringView, filename : StringView, bytes : Bytes, content_type? : String) -> FormData

    追加一个文件字段,返回新的表单。

    content_type 省略时按 application/octet-stream 发送; 传具体类型(如 "image/png")会让服务端更容易识别。

    @moonhttp.FormData::new()
    .append_text("title", "假期照片")
    .append_file("avatar", "a.png", bytes, content_type="image/png")

    FormData::append_text

    fn FormData::append_text(self : FormData, name : StringView, value : String) -> FormData

    追加一个文本字段,返回新的表单。

    FormData::entries

    fn FormData::entries(self : FormData) -> Array[(String, FormValue)]

    展开为「字段名, 值」数组,顺序为追加顺序。

    与 Headers::entries 同理,返回的是新数组:调用方拿到的副本怎么改都不会影响本表单。

    FormData::is_empty

    fn FormData::is_empty(self : FormData) -> Bool

    是否一项都没有。

    FormData::length

    fn FormData::length(self : FormData) -> Int

    字段项数(文本项与文件项都算一项)。

    FormData::new

    fn FormData::new() -> FormData

    空表单。

    FormData::output

    fn FormData::output(self : FormData, logger : &Logger) -> Unit

    FormData::to_repr

    FormData::to_string

    fn FormData::to_string(self : FormData) -> String

    渲染为 FormData { 名: 值, 名: @文件名 (内容类型, N bytes) },只用于日志与断言。

    文件不打印内容(只给字节数):表单经常整个进日志,把文件正文打进去没有意义。

    FormFile

    pub(all) struct FormFile {
    filename : String
    content_type : String
    bytes : Bytes
    }

    一个文件字段:发给服务端的文件名、该部分的内容类型、内容字节。

    FormValue

    pub(all) enum FormValue {
    Text(String)
    File(FormFile)
    }

    表单字段的值:文本或文件。

    Method

    pub(all) enum Method {
    Get
    Head
    Post
    Put
    Delete
    Connect
    Options
    Trace
    Patch
    } derive(Eq, Hash,
    Debug
    )

    HTTP 请求方法。

    这里独立定义一份枚举,而不是直接复用 moonbitlang/async/http 的 RequestMethod, 是为了让 config / headers / merge / url 这几个包保持纯逻辑、不依赖 async 运行时; 两者之间的转换被隔离在 transport 包里。

    派生 Hash 是必须的:Config.method_headers 用 Map[Method, Headers] 存 「按方法分层的默认头」,而 Map 的 key 需要 Hash + Eq。

    Method::equal

    fn Method::equal(Method, Method) -> Bool

    Method::hash

    fn Method::hash(self : Method) -> Int

    Method::hash_combine

    fn Method::hash_combine(Method, Hasher) -> Unit

    Method::not_equal

    fn Method::not_equal(x : Method, y : Method) -> Bool

    Method::parse

    fn Method::parse(name : StringView) -> Method?

    解析方法名,大小写不敏感;无法识别时返回 None。

    不在这里抛错:调用方(传输层或用户代码)才知道该报什么错, 保持这个纯函数只有「找到/没找到」两种结果。

    Method::to_repr

    Method::to_string

    fn Method::to_string(self : Method) -> String

    请求行里使用的大写方法名,例如 GET。用于拼出可读的请求行与日志。

    ProgressEvent

    pub(all) struct ProgressEvent {
    loaded : Int
    total : Int?
    } derive(
    Debug
    )

    一次进度回调带来的信息,对应 axios 原生 ProgressEvent 的核心两个字段。

    刻意只保留能直接从传输过程算出的量:progress() 之外的 rate / estimated (速率与剩余时间)需要读时钟,bytes(本次回调新增的字节)由相邻两次回调 相减也能得到——它们都不该由传输层假装知道。

    与 axios 的差异:没有 upload / download 布尔标记与原生 event 对象, 方向由「哪个回调被调用」表达。

    ProgressEvent::progress

    fn ProgressEvent::progress(self : ProgressEvent) -> Double?

    已传输比例(0.0 ~ 1.0)。

    total 未知或为 0 时返回 None——这时能回答的只有「已经传了多少字节」, 编不出一个比例来。loaded 超过 total 时可能大于 1.0(见 docs/10-progress.md 的「为什么 total 可能不准」),这里不做截断。

    Proxy

    pub(all) struct Proxy {
    protocol : ProxyProtocol?
    host : String?
    port : Int?
    auth : Auth?
    } derive(Default, Eq,
    Debug
    )

    代理服务器设置,对应 axios 的 proxy。

    每个字段都是 Option,与 Config 是同一套「None = 未提供」的语义, 所以它的合并也是逐字段深合并:默认值给了 host、请求给了凭据, 合并后两者都在(见 merge.mbt 的 merge_proxy)。

    只有 host 是必填的:protocol 缺省 Http、port 缺省交给底层按协议补 (http 80 / https 443)、auth 缺省表示匿名代理。 「给了 proxy 却没给 host」是配置错误:is_usable() 返回 false, 上层据此报错,而不是当成没配代理。

    Proxy::default

    fn Proxy::default() -> Proxy

    Proxy::equal

    fn Proxy::equal(Proxy, Proxy) -> Bool

    Proxy::is_usable

    fn Proxy::is_usable(self : Proxy) -> Bool

    这份代理配置能不能用:必须给出非空的 host。

    判定只做一次、两处复用:@util.build_prepared_request 用它决定「拼不出请求」 (返回 None),根包用它决定错误码与文案(ERR_INVALID_URL)。

    之所以不做「缺 host 就当成没配代理」的容错:那会让本该走代理的请求静默地 直连出去,比直接报错危险得多。

    Proxy::not_equal

    fn Proxy::not_equal(x : Proxy, y : Proxy) -> Bool

    Proxy::to_repr

    Proxy::to_string

    fn Proxy::to_string(self : Proxy) -> String

    渲染代理配置:与 Auth::to_string 一样,密码脱敏成 <redacted> (代理凭据同样是凭据,配置进日志时不能明文泄漏)。

    ProxyProtocol

    pub(all) enum ProxyProtocol {
    Http
    Https
    } derive(Eq,
    Debug
    )

    与代理服务器通信用的协议。

    用枚举而不是字符串:取值只有两个,拼错协议名应当是编译错误。 Https 表示到代理本身走 TLS(底层先与代理完成 TLS 握手,再在它上面发 CONNECT),与「目标地址是 https」是两件独立的事——后者在隧道建立之后 还要再叠一层 TLS。

    ProxyProtocol::equal

    ProxyProtocol::not_equal

    fn ProxyProtocol::not_equal(x : ProxyProtocol, y : ProxyProtocol) -> Bool

    ProxyProtocol::to_string

    fn ProxyProtocol::to_string(self : ProxyProtocol) -> String

    协议名的小写取值(http / https)。

    一处实现两处用:Proxy::to_string 的渲染、以及拼代理服务器地址时的那一段 ——它们本来就该是同一个字符串,分开写迟早会不一致。

    ResponseEncoding

    pub(all) enum ResponseEncoding {
    Utf8
    Latin1
    Ascii
    Utf16le
    } derive(Eq,
    Debug
    )

    响应体字节怎么解码成文本,对应 axios 的 responseEncoding(默认 utf-8)。

    它只描述「字节 → 文本」这一步:Response::text() 就是按它解码出来的字符串, Response::json() 也先走这一步、再交给 @json.parse。 本项目不做自动解析(axios 的 responseType / transformResponse 没有对应物):json() 必须显式调用,且不拿 Content-Type 做判断。

    这里只描述怎么解码,不描述读不读。 「读全量还是流」由入口决定: Client::request 读全量并交出响应、Client::stream 交原始字节流、 Client::sse 按规范固定 UTF-8 解析事件。所以这个字段只在 request 那条路上有落点——流式入口连 Response 都没有,解码不是它们的事。

    解码一律是 lossy 的:遇到该编码下非法的字节用替换字符 U+FFFD, 不抛错。响应正文由服务端说了算,为了几个坏字节把整个响应判成失败 得不偿失;要精确字节请读 Response::bytes()。

    ResponseEncoding::equal

    ResponseEncoding::not_equal

    fn ResponseEncoding::not_equal(x : ResponseEncoding, y : ResponseEncoding) -> Bool

    ResponseEncoding::to_string

    fn ResponseEncoding::to_string(self : ResponseEncoding) -> String

    渲染成 axios responseEncoding 风格的取值,用于 Config::to_string 与日志。

    SerializedBody

    pub(all) struct SerializedBody {
    bytes : Bytes?
    content_type : String?
    }

    请求体序列化的结果:待发送字节 + 建议补上的 Content-Type。

    这是 Config 的请求体对外的唯一读法(字段本身是私有的)。

    defaults

    fn defaults() -> Config

    内置默认值,对应 axios 的 lib/defaults/index.js。

    只包含本项目真正会读的字段:withCredentials / onUploadProgress / transformRequest 等暂不支持的能力不在这里出现(见 README 的「暂不支持」清单), 免得出现「配置了但完全不生效」的字段。反过来,支持但没有默认值的字段 (params_serializer 这类钩子)也不出现:None 本身就是「用内置行为」。

    Client::new() 用它作为最底层,用户在 create(config) 里传的配置合并在其之上。

    flatten_headers

    把三层头按 axios 的优先级拍平成一份:common < 按方法 < 请求级平铺。

    axios 在 _request 里做的是 AxiosHeaders.concat(headers.common, headers[config.method], headers) 然后把 common / 各方法名从对象上删掉;concat 的语义是后者覆盖前者, 所以这里从低到高依次 merge 就能得到同样的结果。 flatten_headers(common, None, flat, Get) 这种缺层的情况直接跳过即可, 空层不参与合并不会改变结果。

    merge_config

    fn merge_config(base : Config, request : Config) -> Config

    合并两份配置,返回新配置。base 通常是实例的默认值,request 是本次请求的配置。

    这是 axios mergeConfig(config1, config2) 的对应物。axios 用一张 「字段 → 合并函数」的表来遍历所有键,MoonBit 没有运行时反射, 所以这里改成逐字段显式调用对应策略——好处是每个字段用哪一档策略 在源码里一眼可见,不需要再去查那张表。

    策略分配与 axios 保持一致:
    • 只取请求级:url / http_method / data
    • 请求级优先、否则默认:base_url / timeout / max_redirects / response_encoding / allow_absolute_urls / params_serializer / on_upload_progress / on_download_progress
    • 深合并:params / auth / proxy / headers / common_headers / method_headers
    • 存在即生效:validate_status

    注意 allow_absolute_urls 这类标量在 axios 里走的是默认策略 mergeDeepProperties,而「深合并」作用在标量上退化成「请求级有就用请求级」, 结果与「请求级优先、否则默认」相同。max_redirects 也一样:它不在 axios 的 mergeMap 里,落到默认策略,标量上等价于请求级优先(所以实例默认值里的 max_redirects 会被请求级的值覆盖,包括用 0 关掉跟随)。

    None 一律表示「未提供」,永远是回退而不是覆盖; 数组(例如 params 里的数组)是整体替换而不是拼接, 因为 axios 的 utils.merge 只对普通对象递归。

    merge_json

    fn merge_json(base : Json, request : Json) -> Json

    两个 Json 值的深合并,对应 axios utils.merge 对普通对象的递归合并:
    • 两边都是对象 → 逐键递归合并,只在请求级出现的键补进来;
    • 其余组合 → 请求级整体覆盖。

    「数组整体替换而不是拼接」是刻意的:axios 的 utils.merge 只对 plain object 递归,数组走 val.slice() 直接替换。所以默认值里 tags: ["a"]、 请求里 tags: ["b"] 的结果是 ["b"],而不是 ["a", "b"]。

    merge_json_option

    fn merge_json_option(base : Json?, request : Json?) -> Json?

    Json? 层面的深合并:两侧都可能没有值,None 表示未提供。

    与 merge_headers 同构,唯一区别是合并函数换成了 merge_json。 用于 params 这类以 JSON 对象承载的配置项。