// mooncassette/recorder —— 录制/回放会话引擎。
//
// 分层意图:真实网络能力被隔离在 `Transport` 之后。引擎本身不碰 IO,
// 因此「录制/回放/自动」三种模式的全部逻辑都可以在纯环境下测试。
//
// 脱敏职责归属:**引擎**统一脱敏,而不是各个 Transport 各自处理。
// 这样无论接入哪家的适配器,「写进 cassette 的内容一定经过同一套策略」,
// 不会因为某个适配器忘记脱敏而泄漏密钥。
///|
/// 被录制的底层调用。
///
/// 实现方只需把真实客户端适配到本接口,不必关心录制、匹配、脱敏。
pub(open) trait Transport {
fn send(Self, @core.Request) -> @core.Response raise @core.CassetteError
}
///|
/// 会话模式。
pub(all) enum Mode {
/// 总是调用真实 Transport,并追加记录。
Record
/// 只从 cassette 取;未命中即报错,**绝不**发起真实调用。
Playback
/// 先尝试回放;未命中再调用真实 Transport 并追加记录。
Auto
} derive(Eq)
///|
/// 会话统计。
pub(all) struct Stats {
/// 命中已有录制的次数。
hits : Int
/// 未命中已有录制的次数。
misses : Int
/// 新产生的录制条数。
recorded : Int
} derive(Eq)
///|
/// 录制/回放会话。
///
/// `cursor` 是回放推进位置:命中的记录会被跳过,从而正确处理
/// 「同一请求被录制多次」的场景。`Auto` 模式下每次新录制都会把
/// 游标推到末尾。
pub struct Session {
mut mode : Mode
cassette : @core.Cassette
mut cursor : Int
mut hits : Int
mut misses : Int
mut recorded : Int
transport : &Transport?
policy : @matcher.MatchPolicy
sanitize : @sanitize.SanitizePolicy
}
///|
/// 创建会话。
///
/// 默认模式为 `Playback`(最安全:不会意外发起真实调用),
/// 默认匹配策略为 `Exact`,默认脱敏策略为 `SanitizePolicy::default()`。
pub fn Session::new(
cassette : @core.Cassette,
mode? : Mode,
transport? : &Transport,
policy? : @matcher.MatchPolicy,
sanitize? : @sanitize.SanitizePolicy,
) -> Session {
{
mode: match mode {
Some(value) => value
None => Mode::Playback
},
cassette,
cursor: 0,
hits: 0,
misses: 0,
recorded: 0,
transport,
policy: match policy {
Some(value) => value
None => @matcher.MatchPolicy::Exact
},
sanitize: match sanitize {
Some(value) => value
None => @sanitize.SanitizePolicy::default()
},
}
}
///|
/// 发送一次请求。
///
/// 这里存在一条**关键的不变量**:同一次业务请求会被派生成两种形态,
/// 分别走两条不同链路,二者不可混用。
///
/// - `live`(发给 provider):`normalize` 后直接发出,**保留密钥**。
/// 若这里也脱敏,真实鉴权会失败。
/// - `stored`(用于匹配与落盘):在 `live` 之上再 `sanitize`,**抹掉密钥**。
///
/// 录制与回放两侧都必须走这条同样的链路,指纹才能对齐。否则会出现
/// 「录得进去、回放不出来」——因为落盘的是脱敏版本,而回放查询的是原版。
/// 这个缺陷在早期版本里真实存在过,现在由本函数与 `prepare` 共同保证对称。
///
/// - `Record`:调用真实 Transport,追加记录,返回真实响应;
/// - `Playback`:只从 cassette 取,未命中报 `NoMatch`;
/// - `Auto`:先回放,未命中再录制(即为「首次自动录制」工作流)。
pub fn Session::send(
self : Session,
request : @core.Request,
) -> @core.Response raise @core.CassetteError {
let live = request.normalize()
let stored = @sanitize.sanitize_request(live, self.sanitize)
match self.mode {
Mode::Record => self.capture(live, stored)
Mode::Playback => self.replay_prepared(stored)
Mode::Auto =>
match self.try_match(stored) {
Some(response) => response
None => self.capture(live, stored)
}
}
}
///|
/// 把外部请求转换为「用于匹配与落盘」的形态。
///
/// 顺序是**先规范化、再脱敏**,与 `send` 中的派生顺序严格一致。
fn Session::prepare(self : Session, request : @core.Request) -> @core.Request {
@sanitize.sanitize_request(request.normalize(), self.sanitize)
}
///|
/// 尝试回放。命中则推进游标并返回响应,未命中返回 `None`。
///
/// 调用方只需原样传入业务请求:规范化与脱敏由 `prepare` 统一处理,
/// 因此不会出现「直接调用本函数时忘了脱敏导致命中不了」的陷阱。
pub fn Session::try_replay(
self : Session,
request : @core.Request,
) -> @core.Response? raise @core.CassetteError {
self.try_match(self.prepare(request))
}
///|
/// 在**已准备好**(规范化 + 脱敏)的请求上执行匹配与游标推进。
fn Session::try_match(
self : Session,
request : @core.Request,
) -> @core.Response? raise @core.CassetteError {
let result = @matcher.find_match(
request,
self.cassette.interactions[:],
self.policy,
self.cursor,
)
match result {
Some(found) => {
self.cursor = found.index + 1
self.hits = self.hits + 1
Some(found.interaction.response)
}
None => {
self.misses = self.misses + 1
None
}
}
}
///|
/// 强制回放。未命中时报 `NoMatch`。
///
/// 与 `try_replay` 的区别只在于「未命中」的处置:
/// 这里的失败是显式的,适合在测试里断言「录制覆盖完整」。
pub fn Session::replay(
self : Session,
request : @core.Request,
) -> @core.Response raise @core.CassetteError {
self.replay_prepared(self.prepare(request))
}
///|
/// 诊断「为什么没命中」,不抛错。
///
/// `replay` 失败时抛出的 `NoMatch` 消息里已经包含同样的结论;本函数提供给
/// 需要自行格式化输出的场景,例如 CLI、自定义断言,或「先看清楚再决定是
/// 补录制还是改请求」的调试流程。
///
/// 调用方传业务原样的请求即可:规范化与脱敏由本函数统一处理,与匹配路径
/// 保持一致。
pub fn Session::diagnose(
self : Session,
request : @core.Request,
top? : Int,
) -> @matcher.Diagnosis {
let limit = match top {
Some(value) => value
None => 3
}
@matcher.diagnose(
self.prepare(request),
self.cassette.interactions[:],
self.policy,
self.cursor,
top=limit,
)
}
///|
/// 在**已准备好**的请求上强制回放。未命中时报 `NoMatch`。
fn Session::replay_prepared(
self : Session,
request : @core.Request,
) -> @core.Response raise @core.CassetteError {
match self.try_match(request) {
Some(response) => response
None => {
let total = self.cassette.length()
// 先判耗尽:顺序模式下「游标越界」与「没有对应记录」是两回事,
// 前者的修法是补录制,后者是改请求或换匹配策略。报错了种类,
// 用户就会朝错误的方向排查。
if @matcher.is_exhausted(self.policy, self.cursor, total) {
raise @core.CassetteError::Exhausted(
"sequential playback consumed all " +
total.to_string() +
" recording(s); the test asked for one call more than was recorded",
)
}
raise @core.CassetteError::NoMatch(self.miss_message(request))
}
}
}
///|
/// 拼出未命中时的错误详情:请求指纹 + 一句可操作的原因。
///
/// 只报指纹是不够的——用户拿着一个哈希无法判断该去补录制、改请求,还是
/// 换匹配策略。这里把诊断结论直接放进错误消息,让 `moon test` 的失败输出
/// 本身就能定位问题。
///
/// 传入的 `request` 必须已经规范化(必要时也已脱敏):本函数内部直接调用
/// `@matcher.diagnose`,不再重复 `prepare`。
fn Session::miss_message(self : Session, request : @core.Request) -> String {
let diagnosis = @matcher.diagnose(
request,
self.cassette.interactions[:],
self.policy,
self.cursor,
)
@fingerprint.fingerprint(request) + " (" + diagnosis.hint() + ")"
}
///|
/// 调用真实 Transport 并追加记录。
///
/// `live` 发给 provider(保留密钥),`stored` 写入 cassette(已脱敏)。
/// 响应侧只做脱敏,因为 provider 返回的内容同样可能包含敏感信息
/// (例如回显的鉴权头、或模型复述的密钥)。
fn Session::capture(
self : Session,
live : @core.Request,
stored : @core.Request,
) -> @core.Response raise @core.CassetteError {
let transport = match self.transport {
Some(transport) => transport
None =>
raise @core.CassetteError::MissingTransport(
"this mode needs a real transport, but the session was created without one",
)
}
let response = transport.send(live)
let safe_response = @sanitize.sanitize_response(response, self.sanitize)
self.store(stored, safe_response)
safe_response
}
///|
/// 把一条已准备好的记录并入 cassette,并推进游标与计数。
///
/// 自动录制(`capture`)与手动录制(`record`)共用本函数,这样两条路径在
/// 「游标推进到哪、计数怎么加」上不会各自演化出分歧。
fn Session::store(
self : Session,
stored : @core.Request,
response : @core.Response,
) -> Unit {
self.cassette.push(@core.Interaction::new(stored, response))
self.cursor = self.cassette.length()
self.recorded = self.recorded + 1
}
///|
/// 手动写入一条录制:把「在别处拿到的响应」并入 cassette。
///
/// 主要用途是**异步 HTTP 客户端**。`Transport::send` 是同步的,而
/// `mizchi/x/http` 这类客户端只能在 async 上下文里调用,因此无法实现
/// `Transport`;此时在 async 上下文里手工走一遍即可:
///
/// ```text
/// match session.try_replay(request) {
/// Some(response) => response
/// None => {
/// let response = await my_client.send(request) // 只有这一步是异步的
/// session.record(request, response)
/// }
/// }
/// ```
///
/// 请求与响应都会先经过与 `send` 完全相同的派生链(规范化 + 脱敏),因此
/// 手工录下的记录与自动录下的记录在 cassette 里形态一致,可以互相回放。
/// 换句话说,本函数绕开了 `Transport`,但没有绕开任何不变量。
///
/// 返回值是**已脱敏**的响应,与 `send` 在录制模式下的返回保持一致。保持一致
/// 的原因是确定性:回放返回的必然是 cassette 里存的那一份,若录制时返回未
/// 脱敏的原文,同一段代码在首次运行与后续运行就会看到不同的值。
///
/// 本函数不接触任何外部资源,因此在 `Playback` 模式下调用它也不会破坏
/// 「回放不发起真实调用」这一保证;但它确实会修改 cassette。
pub fn Session::record(
self : Session,
request : @core.Request,
response : @core.Response,
) -> @core.Response {
let stored = self.prepare(request)
let safe_response = @sanitize.sanitize_response(response, self.sanitize)
self.store(stored, safe_response)
safe_response
}
///|
/// 当前统计。
pub fn Session::stats(self : Session) -> Stats {
{ hits: self.hits, misses: self.misses, recorded: self.recorded }
}
///|
/// 当前 cassette(录制模式下即为最新结果,可直接交给 `@codec.encode` 落盘)。
pub fn Session::cassette(self : Session) -> @core.Cassette {
self.cassette
}
///|
/// 当前回放游标。
pub fn Session::cursor(self : Session) -> Int {
self.cursor
}
///|
/// 切换模式。
pub fn Session::set_mode(self : Session, mode : Mode) -> Unit {
self.mode = mode
}
///|
/// 脚本化的假 Transport,用于测试与示例。
///
/// 未配置的请求返回 500:这样在测试里「本不该发生的真实调用」
/// 会立刻表现为可断言的失败,而不是静默通过。
///
/// 注意:脚本按**规范化请求的指纹**索引(注册与查询两侧都会先
/// `normalize`),因此调用方可以按业务原样书写请求,不必关心
/// 对象键顺序或易变字段是否已剔除。
///
/// 这里刻意**不**做脱敏:真实 Transport 收到的是「保留密钥」的请求,
/// Mock 必须与真实行为一致,否则会掩盖脱敏相关问题。
pub struct MockTransport {
scripted : Map[String, @core.Response]
mut calls : Int
}
///|
/// 创建一个空的 MockTransport。
pub fn MockTransport::new() -> MockTransport {
{ scripted: Map([]), calls: 0 }
}
///|
/// 为某个请求登记响应。
pub fn MockTransport::on(
self : MockTransport,
request : @core.Request,
response : @core.Response,
) -> Unit {
self.scripted.set(@fingerprint.fingerprint(request.normalize()), response)
}
///|
/// 实际被调用的次数。
pub fn MockTransport::call_count(self : MockTransport) -> Int {
self.calls
}
///|
pub impl Transport for MockTransport with fn send(self, request) {
self.calls = self.calls + 1
let key = @fingerprint.fingerprint(request.normalize())
match self.scripted.get(key) {
Some(response) => response
None => {
let body : Map[String, Json] = Map([])
body.set("error", Json::string("no scripted response for " + key))
@core.Response::new(500, Json::object(body))
}
}
}