Sign in

    moonhttp

    axios 风格的 MoonBit HTTP 客户端:实例与配置合并、四种请求体形态、流式与 SSE、自动重定向

    http
    client
    axios
    async
    sse
    networking
    Download zip
    Version
    0.6.0
    License
    Apache-2.0
    Last updated
    10 days ago
    Downloads
    130

    #moonhttp

    MoonBit 上的 HTTP 客户端,基于官方异步库 moonbitlang/async:调第三方 REST API、上传下载文件、消费 SSE 流,一次配置就能发请求。

    它提供实例化配置与合并、request / stream / sse 三个入口、五种请求体形态(含流式上传)、自动重定向、代理隧道、上传下载进度回调、请求与响应拦截器、取消请求、SSE 事件解析、响应体 gzip 解压,以及一组按失败原因分类的错误码。API 语义参考 axios,代码为原创实现。

    #安装

    moon add q2316367743/moonhttp

    moon.mod 里会记录:

    import { "q2316367743/moonhttp@0.6.0", }

    默认构建目标是 native。

    #快速开始

    ///|
    async fn main {
    // 实例上一次性配好 base_url 与公共头,之后的请求只写路径
    let api = @moonhttp.create(
    @moonhttp.Config::default()
    .with_base_url("https://api.github.com")
    .with_timeout(5_000)
    .with_common_header("Accept", "application/vnd.github+json"),
    )

    let res = api.request(
    @moonhttp.Config::new("/repos/moonbitlang/core").with_params({
    "per_page": 3,
    }),
    )

    println(res.status) // 200
    println(res.text()) // 响应体原文,按 response_encoding 解码(默认 UTF-8)
    println((try! res.json()).stringify()) // 要对象就 json()
    }

    覆盖更多场景的示例在 src/main/:moon run src/main 跑快速上手那段(文本响应、JSON、查询参数、实例派生、错误处理、拦截器,需要联网);另有七个方向各一个可单独运行的包——basics(状态码 / 头 / 查询参数 / 编码 / 错误 / 超时)、methods(请求方式 × 请求体形态 × 响应内容)、proxy、progress(上传下载进度与 5% 取消)、redirect、interceptors、sse,跑法是 moon run src/main/<方向>。

    #功能与用法

    #实例与配置合并

    配置分三层:内置默认值、实例默认值(create / Client::new 传入的)、本次请求。没设的字段一律回退到上一层:

    字段合并方式
    url / 请求方法 / 请求体只取请求级,实例默认值里的同名字段不参与
    base_url / timeout / max_redirects / response_encoding / 进度回调请求级优先,缺省回退实例默认值
    params / headers / auth / proxy逐层合并(头名大小写不敏感)
    validate_status / params_serializer请求级提供即整体接管

    // 从既有实例派生:继承默认值与拦截器,再叠加本次的配置
    let search = api.create(@moonhttp.Config::default().with_timeout(15_000))

    请求方法缺省时按「实例默认值 → GET」回退,api.defaults() 可以看实例当前的默认配置;状态码默认只认 2xx,想关掉校验就传一个恒真函数(with_validate_status(fn(_) { true }))。九个标准方法之外,Method::Other("PROPFIND") 这类自定义方法按你给的名字原样发送(传输层在建连前校验 token 合法性),WebDAV 等扩展语义开箱即用。

    #三个入口

    入口拿到什么什么时候用
    client.request(config)Response,响应体已读全常规请求
    client.stream(config)StreamResponse,原始字节流下载、自己按块处理
    client.sse(config)SseStream,解析好的事件流消费 SSE

    三者的配置合并与状态码校验完全一致,区别只在响应体怎么读。

    #快捷方法

    get / post / put / delete / patch / head / options 七个方法,都是 request 的薄封装:签名统一是 (url : String, config? : Config),动词与方法名一致。

    // 只要 url
    let repos = api.get("/repos")

    // 其余配置照旧走 config:query、超时、实例默认值(base_url、公共头)都在
    let page = api.get("/repos", config=@moonhttp.Config::default().with_params({ "page": 1 }))

    // 请求体也走 config:形态由 with_data_from_* 决定(json / str / form / urlencoded)
    api.post("/users", config=@moonhttp.Config::default().with_data_from_json({ "name": "moon" }))
    api.delete("/users/1")

    两条优先级:url 位置参数赢过 config.url;动词赢过 config 里的 with_method(...) 与实例默认方法——api.get(...) 一定是 GET。除此之外与 client.request(...) 完全一致:实例默认值、三层头、拦截器、重定向、进度回调、取消、状态码校验一个不少。

    #请求体:四种形态

    构建器发出去的内容自动补的 Content-Type
    with_data_from_str(s)s 的 UTF-8 字节,一个字节不改不补
    with_data_from_json(j)j.stringify() 之后的 JSON 文本application/json
    with_data_from_form(form)multipart/form-data 正文multipart/form-data; boundary=...
    with_data_from_urlencoded(j)a=1&b=2 形式application/x-www-form-urlencoded

    // JSON 请求体
    api.request(@moonhttp.Config::new("/users")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_json({ "name": "moon" }))

    // 带文件的表单:文件按「字节 + 文件名」传入,库不读盘
    let form = @moonhttp.FormData::new()
    .append_text("title", "假期照片")
    .append_file("avatar", "a.png", bytes, content_type="image/png")
    api.request(@moonhttp.Config::new("/upload")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_form(form))

    两点容易踩:「是不是 JSON」由你选的方法决定,不由值的类型决定——with_data_from_json("hi") 发出去的是带引号的 "hi",with_data_from_str("hi") 发出去的是裸 hi;自动补的 Content-Type 只是兜底,你自己设了同名头就一个字节都不改。

    #读响应

    res.text() // 按 response_encoding(默认 UTF-8)解码成文本
    res.bytes() // 原样取出字节,不经过任何解码
    try! res.json() // 先按同一编码解码,再 @json.parse(失败抛 @json.ParseError)
    res.content_length() // 响应体字节数
    res.is_success() // 状态码是不是 2xx

    状态行与响应头在 res.status / res.status_text / res.headers 上。库不做自动解析:json() 必须显式调用,它也不看 Content-Type。

    #压缩响应

    服务端把响应压成 gzip(Content-Encoding: gzip)时,request 先解压再交给你:bytes() / text() 拿到的已经是实体字节,响应头里也不会留着那句已经失效的 Content-Encoding。请求侧由库声明 Accept-Encoding: gzip, identity,你自己设过就以你的为准。想自己处理压缩字节(例如把 .tar.gz 原样转存)就用 with_decompress(false):

    // 关掉自动解压:请求侧改声明 identity,响应体与 Content-Encoding 都保持线上原样
    let res = client.request(
    @moonhttp.Config::new("/package.tgz").with_decompress(false),
    )
    let compressed : Bytes = res.bytes()

    只支持 gzip——moonbitlang/async 只提供这一种编解码器。别的编码(br / deflate)既不解压也不报错,字节与头原样交给你。两个流式入口(stream / sse)的 gzip 解压由底层传输实现透明完成,with_decompress(false) 同样能关掉。解压失败时抛 BadResponse(ERR_BAD_RESPONSE),错误里带着原始字节与那句 Content-Encoding。

    #拦截器

    请求侧在发送前改配置,响应侧在拿到响应后改响应;两侧各有一个错误处理器,按失败来源分流——请求阶段的失败(被拦下、连不上、超时、被取消、重定向超限)走请求侧,状态码没通过校验(默认 2xx 以外的响应)走响应侧。拦截器挂在实例上:

    let plain = @moonhttp.Client::new() // 给重试用的裸实例:不带这层拦截器,天然不会无限递归
    let client = @moonhttp.Client::new(
    interceptors~ = @moonhttp.Interceptors::new()
    // 请求侧:发送前改配置(加认证头、改地址、给所有请求注入公共 body 字段)
    .use_request(config => config.with_header("X-Token", "secret"))
    // 请求侧的错误路径:网络类失败的重试写这(下面两处是同一个写法,写在哪一侧
    // 决定它会看到哪一类失败)
    .use_request(config => config, on_rejected=error => plain.request(error.config()))
    // 响应侧:原样返回就只是观察;要改就用 with_status / with_headers / with_body / with_text / with_json
    .use_response(response => response)
    // 响应侧的错误路径:只有「请求成功、状态码不是 2xx」会走到这——按状态码分支的重试与降级写这
    .use_response(response => response, on_rejected=error => plain.request(error.config())),
    )

    • 顺序:请求侧后注册先跑(LIFO)、响应侧先注册先跑(FIFO),两个方向相反。
    • 错误处理器返回一个 Response 就是「这次失败救回来了」(它直接成为 request 的结果);救不回就 raise error 继续往外抛。
    • 拦截器拿到的配置是合并后的;client.create(...) 派生的实例会继承这份链。
    • 一次请求只跑一遍:跟 5 跳重定向也只跑一次。两个错误处理器与响应链都只作用于 Client::request——两个流式入口只过请求拦截器。
    • 拦截器闭包写成箭头形式(config => ...),或在签名上标 async fn。
    • 给一条起名(use_request(f, name="auth"))就可寻址:同名再注册替换原位(换 token 源不必先撤再注册),remove_request("auth") 按名撤下,名字没注册过返回 None。要给某个实例少挂一条,就把裁过的链交给它:Client::new(interceptors~ = base.remove_response("unwrap").unwrap())。语义细则见 docs/11。

    #取消请求

    取消用 AbortController + AbortSignal:把控制器的 signal 传给任意入口(request / stream / sse 与七个快捷方法都收 signal? 参数),在需要的时候 abort() 叫停这次请求:

    let stop = @moonhttp.AbortController::new()

    @async.with_task_group(group => {
    let running = group.spawn(() => {
    api.request(
    @moonhttp.Config::new("/reports/big.csv"),
    signal=stop.signal(),
    ) catch {
    error if error.is_cancelled() => println("已取消:" + error.message())
    }
    })
    @async.sleep(2_000)
    stop.abort(reason="Operation canceled by the user.")
    running.wait()
    })

    被取消的请求抛 HttpError,code 是 Cancelled,message 就是传给 abort 的 reason。取消发生在响应头到手之后时,错误里带着已经收到的部分响应;流式入口在读取过程中被取消同样抛 Cancelled。signal 是一次性的,abort 之后永远保持取消状态;同一个 signal 可以传给多个请求,abort 一次全部生效。喊停不必在同一处:另一条协程、看门狗都可以。快捷方法同形:api.get(url, signal=stop.signal())。

    #自动重定向

    max_redirects 默认 5 跳,设成 0 就是不跟随(3xx 原样交给状态码校验),三个入口都跟。跨 host 跟随时会丢掉 Authorization / Cookie 这类显式凭据;挂了 cookie 罐的客户端不受这条影响——罐里的 cookie 每跳按新地址重新做域匹配(见下一节)。跟到超限抛 TooManyRedirects,错误里带着最后那个 3xx 响应。

    创建实例时挂一个 CookieJar,之后的 cookie 维护就全自动:请求按 URL 自动携带、响应的 Set-Cookie 自动解析入库、过期自动失效;重定向的每一跳都按当跳地址重新匹配。不传罐(缺省)则完全不碰 cookie,行为与从前一致:

    let jar = @moonhttp.CookieJar::new()
    let client = @moonhttp.Client::new(cookie_jar=jar)
    ignore(client.request(@moonhttp.Config::new("https://example.com/login"))) // 响应种下会话 cookie
    ignore(client.request(@moonhttp.Config::new("https://example.com/profile"))) // 自动带上

    规则对齐 RFC 6265 的精简版:没带 Domain 属性的 cookie 只回当初那台主机;带 Domain 的对子域可见(Secure 只随 https 发送,端口不参与匹配);Max-Age 优先于 Expires,Max-Age=0 或已过期的 Expires 按删除处理,无过期属性就是会话 cookie(随 jar 存活)。你自己在请求里显式设置的 Cookie 头永远优先。instance.create(config) 派生的实例与父实例共享同一个罐,换默认值不丢会话状态。已知边界:没有公共后缀列表(Domain=com 这类不会被拒绝),Expires 只认标准格式(Sun, 06 Nov 1994 08:49:37 GMT)。 jar 也可以单独用:store(url, set_cookie) 喂、cookie_header(url) 取,两个 Client 挂同一个罐即共享登录态。

    #代理

    let client = @moonhttp.create(
    @moonhttp.Config::default()
    .with_base_url("https://api.example.com")
    .with_proxy("127.0.0.1", port=9000, username="mikeymike", password="rapunz3l"),
    )

    http 与 https 目标都经 CONNECT 隧道转发(代理服务器得支持 CONNECT)。username / password 只用在建隧道那一步,不会发给目标服务器。只支持 http / https 代理,不读 http_proxy 之类的环境变量;配了代理却没给 host 会直接报错,不会悄悄直连。

    #上传与下载进度

    client.request(
    @moonhttp.Config::new("/upload")
    .with_method(@moonhttp.Method::Post)
    .with_data_from_json({ "name": "moon" })
    .with_on_upload_progress(fn(event) { println("已上传 \{event.loaded} 字节") })
    .with_on_download_progress(fn(event) { println("已下载 \{event.loaded} 字节") }),
    )

    ProgressEvent 只有 loaded(已传输字节)与 total(总字节,None 表示长度未知——chunked 响应就是这种),外加算比例的 progress();方向由哪个回调被调用表达。下载进度由「库读全量」的两条路(request 与 read_all)触发,自己按块读时自行累加。压缩响应下报的是线上字节(收到的压缩数据),所以 loaded 不会超过 total。回调是同步执行且不允许抛错的,别在里面做耗时的事。

    #流式响应与 SSE

    request 会把响应体读全,SSE 这类一直不结束的响应要用另外两个入口。

    ///|
    async fn download(api : @moonhttp.Client) -> Unit raise @moonhttp.HttpError {
    let res = api.stream(@moonhttp.Config::new("/big-file"))
    println(res.status) // 响应头已到手,body 还没读
    while res.read_some() is Some(chunk) {
    println(chunk.length())
    }
    }

    ///|
    async fn watch(api : @moonhttp.Client) -> Unit raise @moonhttp.HttpError {
    let events = api.sse(@moonhttp.Config::new("/events"))
    while events.next_event() is Some(event) {
    println(event.event + ": " + event.data) // message: {...}
    }
    }

    StreamResponse 还有 read_all() 与 read_until(分隔符);SseEvent 带 event(缺省 "message")、data、id 与 retry(后两者是持久状态,断线重连要用)。读到 EOF 会自动关连接,中途结束时记得自己 close()——忘记关会漏一条连接。Client::sse 要求响应头声明 text/event-stream,拿到的不是事件流会报 NotSupported;服务端不声明却是 SSE 的场合,用 Client::stream 配公开的 SseParser 自己驱动。

    #错误处理

    失败抛 HttpError,它带着错误分类、出错时的配置,以及已经收到的响应(None 表示连响应头都没收到)。构造器模式要写类型限定的形式(HttpError::HttpError——pub using 再导出类型但不导出构造器,见 docs/01-architecture.md):

    try {
    ignore(api.request(config))
    } catch {
    @moonhttp.HttpError::HttpError(info) => {
    println(info.code) // BadRequest
    println(info.message) // 请求失败,状态码 404
    println(info.response.unwrap().status) // 404
    println(info.config.url) // 出错时的配置,便于定位
    }
    }

    ErrorCode触发时机
    BadRequest状态码 4xx 且未通过校验
    BadResponse状态码 5xx(或其它非 2xx)
    Network连接 / DNS / TLS 失败、读响应体中途断连、代理拒绝建隧道
    Timeout超过 timeout
    Cancelled被取消信号叫停(error.is_cancelled())
    InvalidUrl既没有 url 也没有可用的 base_url
    NotSupported传输层无法完成该请求(例如重定向到非 http(s) 协议)
    TooManyRedirects重定向次数超过 max_redirects

    #用 Mock 传输层测试

    传输层是可替换的 Transport trait,测试时换掉真实网络(记得在自己的 moon.pkg 里加上 "q2316367743/moonhttp/transport"):

    let mock = @transport.MockTransport::new(response)
    let transport : &@transport.Transport = mock
    ignore(@moonhttp.Client::new(transport=transport).request(@moonhttp.Config::new("/users")))
    println(mock.last_request().unwrap().url) // 已经拼好 base_url 与 query 的完整地址

    MockTransport 会记下收到的每个请求,也能预置一串响应或固定失败(from_responses / failing);自定义传输只需实现一个 send 方法。

    #暂不支持与后续计划

    以下能力本版没有实现,配置里也不会出现对应字段:

    • 连接复用:每次请求新建连接,计划做连接池以省掉重复握手。
    • SSE 自动重连:id / retry 已经作为持久状态带出来了,按 retry 间隔重订阅留给上层;计划内置。
    • 响应体自动解析:读法由你在 text() / bytes() / json() 里显式选;静态类型下「猜内容类型」要先重新设计响应类型的形态,留待后续版本。
    • transformRequest / transformResponse:不做成独立配置项——用两段拦截器代替(请求侧改 body、响应侧改正文,见上面「拦截器」一节)。
    • 拦截器的 runWhen(按条件跳过)与 synchronous(批量注册):条件写在拦截器体内 if 即可,批量注册用链式 use_*;eject 的等价物是具名注册 + remove_request / remove_response。
    • 重定向的 beforeRedirect 回调与自定义敏感头名单。
    • 代理的 SOCKS 支持(自研传输 HttpConnTransport 的下一个提交补 SOCKS5)、http_proxy / no_proxy 环境变量,以及按请求关掉代理的开关(显式 with_proxy 已支持)。
    • 单条头的多值:一个头名只能对应一个字符串值。
    • deflate / br 等其它压缩编码:响应解压只做 gzip(moonbitlang/async 只提供 gzip 的编解码器);别的编码既不解压也不报错,字节与 Content-Encoding 原样交给你。

    #参与开发

    代码在 src/ 下(七个功能包 + 一组可运行示例),实现思路、契约与改动清单在 docs/。

    moon check # 类型检查 moon test # 全部测试(不需要外网) moon run src/main # 快速上手示例(真实网络,本地手动测试用,不随包发布) moon run src/main/proxy # 其余七个方向同理:把 <方向> 换成 basics/methods/proxy/ # progress/redirect/interceptors/sse 之一 moon info && moon fmt # 更新 .mbti 接口并格式化,提交前跑一次

    示例的索引(每个演示什么、前置条件是什么)在 src/main/README.md。

    提交前建议把钩子装上,每次 commit 会自动跑一遍 moon check:

    chmod +x .githooks/pre-commit && git config core.hooksPath .githooks

    CI 会跑 moon check --deny-warn、moon build、moon test,并要求 .mbti 接口文件与 moon fmt 的结果没有 diff。动手改代码前先看 docs/README.md 末尾的「改动时的同步清单」。

    #许可证

    Apache-2.0,见 LICENSE。

    AbortController

    取消原语:三个入口与七个快捷方法的 signal? 参数类型是 AbortSignal, 而取消之后调用方还要拿它查状态(aborted / reason),叫停则要用 AbortController。信号不是配置字段,所以直接从定义它的 abort 包再导出。

    AbortSignal

    取消原语:三个入口与七个快捷方法的 signal? 参数类型是 AbortSignal, 而取消之后调用方还要拿它查状态(aborted / reason),叫停则要用 AbortController。信号不是配置字段,所以直接从定义它的 abort 包再导出。

    Auth

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    Client

    编排层:客户端实例、拦截器集合、两个流式类型,以及两段拦截器的 四个函数类型;create / default_client 两个工厂也随类型一起再导出。

    Config

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    CookieJar

    cookie 罐:Client::new(cookie_jar~) 的入参类型。 传入后请求按 URL 自动携带、响应自动存取、过期自动失效, 契约见 docs/19-cookies.md。

    ErrorCode

    错误的分类,对应 axios 的 AxiosError.code。

    分成这几档是为了让调用方能按「该怎么处理」分支: 超时可以重试,网络失败要看连接,4xx 要看自己的请求参数, 5xx 要等服务端恢复。

    ErrorInfo

    错误的详细信息,对应 AxiosError 上的 message / code / config / response。

    FormData

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    FormFile

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    FormValue

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    Headers

    大小写不敏感的 HTTP 头集合,对应 axios 的 AxiosHeaders。

    内部以小写化后的头名作为 Map 的 key,值里再记住写入时的原始拼写, 于是 get("Content-Type") 与 get("content-type") 命中同一条记录, 而遍历/打印时仍然保留调用方书写时的形状(与 axios「首次出现的拼写胜出」一致)。

    与 axios 相比的两处有意简化(README 里有完整清单):
    • 不支持 axios 用 false 表示「这条头禁止被默认值覆盖」的哨兵值;
    • 同一个头名只能有一个字符串值,不展开多值头(axios 允许数组)。

    所有变更方法都返回新实例而不是就地修改:Config 是值语义的, 合并配置时同一份 Headers 可能被多个 Config 引用, 返回新值可以避免「改一个实例的头,另一个实例跟着变」这类共享可变状态 bug。

    HttpError

    本模块对外的错误类型。

    用 pub(all) suberror 而不是 pub suberror:后者不导出构造子, 使用方就只能拿到错误、无法按分类匹配。这里刻意把构造子放开, 让调用方能写 catch { @moonhttp.HttpError::HttpError(info) => ... } ——构造器不做包限定裸写不出来(pub using 再导出不了构造器, 实测证据见 docs/01-architecture.md),类型限定的写法不受影响。

    同理,client 包的抛错点也靠 pub(all) 跨包构造本类型。

    Interceptors

    编排层:客户端实例、拦截器集合、两个流式类型,以及两段拦截器的 四个函数类型;create / default_client 两个工厂也随类型一起再导出。

    Method

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    ProgressCallback

    进度回调。

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

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

    ProgressEvent

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    Proxy

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    ProxyProtocol

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    RequestErrorHandler

    请求侧的错误处理器,对应 axios 里 interceptors.request.use(f, r) 的第二个 参数(但覆盖面不同:axios 的请求侧 r 接不到派发失败,这里接得到)。

    拿到的是请求阶段的失败:被请求拦截器拦下、配置拼不出可发送的请求(缺 url、 代理缺 host)、连不上、超时、被取消、重定向超限、读响应体的中途失败。 返回一个 Response 表示这次失败就地救回来,它会作为 request 的结果返回; raise error 表示不处理、交给更早注册的错误处理器(请求侧错误链是后注册 先跑),都没有接住就抛给调用方——与 axios 的 Promise.reject(error) 等价。

    救回的响应不再经过响应侧链:响应侧管的是「服务端这次返回的响应」,而救回的 多半是缓存里存的、或本地兜底合成的,两段链各管一段。要让它也走一遍响应改写, 在处理器里自己调一次改写函数即可。

    重试写在处理器体内:拿 error.config() 用另一个不带这层拦截器的实例再发一次 (与响应侧那份配方一样),见 docs/11-interceptors.md。

    签名与 ResponseErrorHandler 完全相同,分开命名是为了让两段链各自自解释、 也让 .mbti 一眼看出这个处理器属于哪一侧。

    RequestInterceptor

    请求拦截器:拿到合并后的配置,返回(可能改写过的)配置。

    拿到的已经是「实例默认值 + 本次请求配置」的合并结果(与 axios 一致),所以 可以直接改地址、加头、换请求体,甚至换方法。抛错表示拦下这次请求:请求 不会发出,错误按来源分流交给请求侧的错误处理器 (HttpError::new 就是给这种中止准备的,见 http_error 包)。

    类型带 async 与 raise HttpError 不是装饰:raise 让拦截器能中止请求, async 让「发请求前先去别处取一个 token」这类事写在同一处。代价是具名同步 函数不能直接传进来——MoonBit 的效果推断只认箭头语法,写成 config => ... 或用 async fn 显式标注即可。

    Response

    响应值与错误契约(定义在 response / http_error 包)。

    ResponseEncoding

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    ResponseErrorHandler

    响应侧的错误处理器,对应 axios 里 interceptors.response.use(f, r) 的第二个参数。

    只接「请求已经成功、但状态码没通过校验」(默认只放行 2xx):这时 HttpError::response() 上有完整响应,状态码、响应头、错误正文都在手里,可以就地 降级成正常响应、改写成统一形状,或重发一次。请求阶段的失败(连不上、超时、 被取消、被请求拦截器拦下)不进这里,它们由 RequestErrorHandler 接 ——这条分流是本项目与 axios 的 promise 链有意不同的地方,理由见 docs/11-interceptors.md。

    返回一个 Response 表示这个错误已经处理掉,它会作为 request 的结果 返回;raise error 表示不处理、继续往外传——与 axios 的 Promise.reject(error) 等价。重试就是在这里再发一次请求。

    ResponseInterceptor

    响应拦截器(正常路径):拿到最终的 Response,返回(可能改写过的)响应。

    只在 Client::request 上跑:stream / sse 的「响应」是还没读的字节流, 改写与重试都没有明确语义(读法由入口决定是本项目已有的口径)。改写响应用 Response::with_status / with_headers / with_body / with_text / with_json; 「解析 → 处理 → 写回」的配方(json() 抛 @json.ParseError,要用 try ... catch 收掉)见 docs/11-interceptors.md。

    SerializedBody

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    SseEvent

    SSE 解析器:它是独立的纯逻辑包,既能被 Client::sse 驱动, 也能用在别的字节来源上(文件、WebSocket),是「服务端不声明 text/event-stream 时自己接」的公开逃生口。

    SseParser

    SSE 解析器:它是独立的纯逻辑包,既能被 Client::sse 驱动, 也能用在别的字节来源上(文件、WebSocket),是「服务端不声明 text/event-stream 时自己接」的公开逃生口。

    SseStream

    编排层:客户端实例、拦截器集合、两个流式类型,以及两段拦截器的 四个函数类型;create / default_client 两个工厂也随类型一起再导出。

    StreamBody

    配置与请求体相关类型:with_data_from_form 的入参是 FormData, serialize_body 的返回值是 SerializedBody,流式请求体(docs/20)的载荷 是 StreamBody——自定义传输实现按 RequestBody::Stream 解构时要用到它。 进度回调的类型同理:with_on_upload_progress 的入参是 ProgressCallback。

    StreamResponse

    编排层:客户端实例、拦截器集合、两个流式类型,以及两段拦截器的 四个函数类型;create / default_client 两个工厂也随类型一起再导出。

    Transport

    传输层抽象:自定义传输实现是公开的扩展点。

    create

    创建实例的便捷入口,对应 axios.create(config)。

    config 是位置参数(与 axios 的 axios.create(config) 一致), 会叠加在内置默认值之上。不需要任何定制时用 default_client(); 需要注入传输层(例如测试里换成 MockTransport)时用 Client::new(config~, transport~)。

    default_client

    用内置默认值 + 真实传输层创建一个默认实例。

    与 axios 的全局 axios 不同,本模块没有可变的全局状态, 所以这里是一个工厂函数而不是一个共享单例: 想定制默认值请用 create(...) 创建,或从已有实例用 client.create(...) 派生。

    Source Files