moonbit-project

    MoonBit HTTP interaction recording, replay, and offline contract testing library

    http
    testing
    record-replay
    offline
    Download zip
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    20 hours ago
    Downloads
    2

    #MoonVCR

    MoonVCR:MoonBit HTTP 交互录制回放与离线契约测试库

    MoonVCR 将经过允许的 HTTP 请求与响应保存为可审阅的 cassette,并在开发和 CI 中离线回放。它面向需要稳定、可重复测试的 MoonBit SDK、服务端和内部 API。

    0.1.0 核心版本已经完成一条可运行的闭环:调用方显式提供 transport,MoonVCR 负责记录、脱敏、回放和离线诊断。当前版本已经包含:

    • 版本化的请求、响应、请求头、请求体、交互和 cassette 模型;
    • 确定性 JSON 编码与解码;
    • 未知 schema 版本和损坏 JSON 的类型化错误;
    • URL、查询参数和请求头规范化,以及可配置的 body 匹配策略;
    • 确定性 matcher、顺序消费和候选摘要;
    • 显式 transport 的 record、replay 与 strict-offline 会话;
    • 默认敏感头/查询参数清理,以及可配置 JSON Pointer/dot 路径清理;
    • 不泄露原文的字段级 mismatch 诊断;
    • 有限响应脚本 transport 与无网络可运行示例;
    • 无网络即可运行的核心测试。

    #安装与最小用法

    Mooncakes 发布完成后,在你的 MoonBit 项目中添加 0.1.0 版本:

    moon add chenqi-arch/moonbit-project@0.1.0

    在代码中导入根包并创建会话:

    import { "chenqi-arch/moonbit-project" @moonvcr, }

    let cassette = @moonvcr.Cassette::decode(cassette_text)
    let session = @moonvcr.Session::with_defaults(
    cassette,
    @moonvcr.SessionMode::StrictOffline,
    )
    let result = session.send(
    request,
    offline_transport,
    )

    上面是 API 轮廓,cassette_text、request 和 offline_transport 由调用方提供;可直接运行的完整版本见 cmd/moonvcr-demo。回放只依赖内存中的 cassette 文本,不会因为回放未命中而偷偷联网。录制时则由调用方把现有 HTTP 客户端封装成 transport,并明确决定何时访问真实网络。

    #本地验证

    安装 MoonBit 工具链后,在仓库根目录运行:

    moon check moon test moon fmt --check

    命令示例会在 transport 被调用时主动失败;成功输出 offline replay status=200 即证明 strict-offline 回放没有触网。cassette 是纯文本 JSON,变更可以通过 Git 逐行审阅。 record 模式只调用调用方显式传入的 transport,replay 和 strict-offline 模式不会调用 transport;核心库不拦截系统流量,也不会暗中联网。

    #最小内存回放

    回放不需要网络或文件系统,调用方可以把 cassette 文本交给 Cassette::decode,再创建 Replay 会话:

    let cassette = Cassette::decode(cassette_text)
    let session = Session::with_defaults(cassette, Replay)
    let offline_transport = (_ : Request) => {
    Err(TransportError::Failed("network disabled"))
    }
    let response = session.send(request, offline_transport)

    匹配会规范化方法、URL/query、选定的 header 和 body,并按 cassette 顺序消费重复请求。未命中返回 SessionError::ReplayMiss,而不是尝试访问网络。

    #项目边界

    MoonVCR 是原创 MoonBit 实现,不机械移植其他语言的 VCR 源码。项目借鉴 HTTP 录制/回放工具的通用思想,但会保持自己的数据结构、匹配语义和 MoonBit API。第一版不实现 TLS MITM、系统代理或自动拦截任意进程流量。录制前仍应审核请求和响应中是否存在个人数据;默认脱敏器会优先清理常见凭据,业务专用字段请通过 RedactionConfig 显式配置。

    #显式录制

    MoonVCR 不会自行拦截网络。调用方把已有 HTTP 客户端封装成 transport,再交给 Record 会话;transport 收到的是真实请求,cassette 中保存的是脱敏副本:

    let session = Session::with_defaults(Cassette::empty(), Record)
    let transport = (request : Request) => {
    // 在这里调用你的 HTTP 客户端,并转换成 Response。
    http_client_send(request)
    }
    let response = session.send(request, transport)
    let cassette_text = Cassette::encode(session.cassette())

    如果暂时没有 HTTP 客户端,可以用仓库自带的有限响应适配器做确定性测试:

    let scripted = ScriptedTransport::new([
    { status: 200, headers: [], body: Text("ok"), },
    ])
    let session = Session::with_defaults(Cassette::empty(), Record)
    let response = session.send(request, (request) => scripted.send(request))

    RedactionConfig::default() 会清理常见 Authorization、Cookie、API key、token 和签名字段。 还可以在 json_paths 中配置 /credentials/token 或 credentials.token;配置路径缺失或 JSON 无效时返回 SessionError::RedactionFailed,该交互不会写入 cassette。

    #诊断与安全

    未命中返回 SessionError::ReplayMiss,而不是尝试访问网络。需要查看原因时,可以在同一个 会话上调用 session.diagnose(request)。摘要只包含字段类型、长度和指纹,不包含未知 header、 body 或凭据原文。运行仓库中的离线复现命令:

    moon run cmd/moonvcr-demo

    #许可证

    Apache License 2.0,详见 LICENSE

    CassetteError

    pub(all) suberror CassetteError {
    InvalidJson
    InvalidSchema
    UnsupportedVersion(Int)
    } derive(Eq,
    Debug
    )

    Errors raised while reading a cassette.

    Body

    pub(all) enum Body {
    Empty
    Text(String)
    Base64(String)
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A request or response body.

    BodyMatchMode

    pub(all) enum BodyMatchMode {
    Exact
    Ignore
    } derive(Eq,
    Debug
    )

    Controls which request fields participate in cassette matching.

    Cassette

    pub(all) struct Cassette {
    format_version : Int
    interactions : Array[Interaction]
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A versioned collection of recorded HTTP interactions.

    Cassette::decode

    fn Cassette::decode(text : String) -> Cassette raise CassetteError

    Decode a cassette and reject schema versions this release cannot handle.

    Cassette::empty

    fn Cassette::empty() -> Cassette

    Create an empty cassette using the current schema version.

    Cassette::encode

    fn Cassette::encode(self : Cassette) -> String

    Encode a cassette as deterministic JSON text.
    pub(all) struct Header {
    name : String
    value : String
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    A HTTP header stored in a cassette.

    Interaction

    pub(all) struct Interaction {
    request : Request
    response : Response
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    One request/response pair in a cassette.

    MatchCandidate

    pub(all) struct MatchCandidate {
    index : Int
    key : String
    } derive(Eq,
    Debug
    )

    A cassette entry considered during request matching.

    MatchConfig

    pub(all) struct MatchConfig {
    include_headers : Array[String]
    ignore_headers : Array[String]
    body_mode : BodyMatchMode
    } derive(Eq,
    Debug
    )

    Header and body matching policy for a session.

    MatchConfig::default

    fn MatchConfig::default() -> MatchConfig

    Return the conservative default matching policy.

    MatchResult

    pub(all) enum MatchResult {
    Matched(Int)
    NoMatch(Array[MatchCandidate])
    } derive(Eq,
    Debug
    )

    The result of matching a request against the unconsumed cassette entries.

    Mismatch

    pub(all) struct Mismatch {
    candidate_index : Int
    kind : MismatchKind
    expected : String
    actual : String
    } derive(Eq,
    Debug
    )

    One safe, structured difference for a candidate cassette entry.

    MismatchKind

    pub(all) enum MismatchKind {
    NoCandidate
    Method
    Url
    Query
    Headers
    Body
    Sequence
    } derive(Eq,
    Debug
    )

    The field that prevented a request from matching a cassette entry.

    MismatchKind::label

    fn MismatchKind::label(self : MismatchKind) -> String

    MismatchReport

    pub(all) struct MismatchReport {
    request_key : String
    differences : Array[Mismatch]
    } derive(Eq,
    Debug
    )

    A deterministic mismatch report suitable for test output.

    MismatchReport::summary

    fn MismatchReport::summary(self : MismatchReport) -> String

    Render a stable human-readable report. Values are already reduced to safe descriptors by the diagnostic builder.

    RedactionConfig

    pub(all) struct RedactionConfig {
    headers : Array[String]
    query_parameters : Array[String]
    json_paths : Array[String]
    } derive(Eq,
    Debug
    )

    Additional redaction rules supplied by the caller.

    Header and query names are compared case-insensitively. json_paths accepts JSON Pointer paths such as /credentials/token and a dot shorthand such as credentials.token. Array indexes are supported by JSON Pointer segments.

    RedactionConfig::default

    Conservative defaults for credentials commonly sent by HTTP clients.

    RedactionError

    pub(all) enum RedactionError {
    InvalidJson(String)
    InvalidPath(String)
    MissingPath(String)
    } derive(Eq,
    Debug
    )

    Errors raised when a configured value cannot be removed safely.

    Request

    pub(all) struct Request {
    method : String
    url : String
    headers : Array[Header]
    body : Body
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    The request captured in one interaction.

    Response

    pub(all) struct Response {
    status : Int
    headers : Array[Header]
    body : Body
    } derive(Eq, ToJson,
    Debug
    ,
    FromJson
    )

    The response captured in one interaction.

    ScriptedTransport

    pub(all) struct ScriptedTransport {
    responses : Array[Response]
    cursor : Int
    } derive(
    Debug
    )

    A deterministic transport adapter backed by a finite response script.

    The adapter is useful for examples and tests: it never opens a socket, and once the script is exhausted it returns a typed error instead of guessing a response.

    ScriptedTransport::new

    ScriptedTransport::remaining

    fn ScriptedTransport::remaining(self : ScriptedTransport) -> Int

    ScriptedTransport::send

    fn ScriptedTransport::send(self : ScriptedTransport, _request : Request) -> Result[Response, TransportError]

    Return the next scripted response, or fail when no response remains.

    Session

    pub(all) struct Session {
    cassette : Cassette
    config : MatchConfig
    redaction : RedactionConfig
    mode : SessionMode
    consumed : Array[Bool]
    } derive(
    Debug
    )

    Stateful record/replay session. Replay never invokes its transport.

    Session::cassette

    fn Session::cassette(self : Session) -> Cassette

    Return the cassette, including interactions recorded so far.

    Session::diagnose

    fn Session::diagnose(self : Session, request : Request) -> MismatchReport

    Explain why a request would miss the current cassette.

    Session::new

    fn Session::new(cassette : Cassette, mode : SessionMode, config : MatchConfig) -> Session

    Create a session with an explicit matching policy.

    Session::new_with_redaction

    fn Session::new_with_redaction(cassette : Cassette, mode : SessionMode, config : MatchConfig, redaction : RedactionConfig) -> Session

    Create a session with explicit matching and redaction policies.

    Session::send

    fn Session::send(self : Session, request : Request, transport : (Request) -> Result[Response, TransportError]) -> Result[Response, SessionError]

    Send one request through the selected mode.

    Session::with_defaults

    fn Session::with_defaults(cassette : Cassette, mode : SessionMode) -> Session

    Create a session using the conservative default matching policy.

    SessionError

    pub(all) enum SessionError {
    ReplayMiss(Array[MatchCandidate])
    TransportFailed(TransportError)
    RedactionFailed(RedactionError)
    } derive(Eq,
    Debug
    )

    Errors returned by the record/replay session.

    SessionMode

    pub(all) enum SessionMode {
    Record
    Replay
    StrictOffline
    } derive(Eq,
    Debug
    )

    Selects whether a session records through a transport or serves a cassette.

    TransportError

    pub(all) enum TransportError {
    Failed(String)
    } derive(Eq,
    Debug
    )

    A transport adapter supplied by the host application.

    CURRENT_FORMAT_VERSION

    let CURRENT_FORMAT_VERSION : Int

    The cassette schema supported by this release.

    REDACTION_MARKER

    let REDACTION_MARKER : String

    The fixed marker written in place of a value that must not enter a cassette.

    diagnose_mismatch

    fn diagnose_mismatch(request : Request, cassette : Cassette, config : MatchConfig, consumed : Array[Bool]) -> MismatchReport

    Build a report using the default conservative redaction rules.

    diagnose_mismatch_with_redaction

    fn diagnose_mismatch_with_redaction(request : Request, cassette : Cassette, config : MatchConfig, consumed : Array[Bool], redaction : RedactionConfig) -> MismatchReport

    Build a safe mismatch report while honoring consumed-entry sequence.

    example_cassette

    fn example_cassette() -> Cassette

    Return a small, useful example for documentation and tests.

    match_candidates

    fn match_candidates(request : Request, cassette : Cassette, config : MatchConfig, consumed : Array[Bool]) -> Array[MatchCandidate]

    Return every unconsumed entry, retaining its normalized key for diagnostics.

    match_request

    fn match_request(request : Request, cassette : Cassette, config : MatchConfig, consumed : Array[Bool]) -> MatchResult

    Match the first unconsumed interaction with an identical normalized key.

    normalize_request

    fn normalize_request(request : Request, config : MatchConfig) -> String

    Build a deterministic, transport-independent key for a request.

    normalize_url

    fn normalize_url(url : String) -> String

    Remove fragments and sort query pairs without decoding user data.

    redact_interaction

    fn redact_interaction(interaction : Interaction, config : RedactionConfig) -> Result[Interaction, RedactionError]

    Redact both sides of an interaction before it is persisted.

    redact_request

    fn redact_request(request : Request, config : RedactionConfig) -> Result[Request, RedactionError]

    Return a request safe to store in a cassette.

    redact_response

    fn redact_response(response : Response, config : RedactionConfig) -> Result[Response, RedactionError]

    Return a response safe to store in a cassette.

    request_match_key

    fn request_match_key(request : Request, config : MatchConfig) -> String

    Build the same key used by the matcher for diagnostics and tooling.