///|
/// Selects whether a session records through a transport or serves a cassette.
pub(all) enum SessionMode {
Record
Replay
StrictOffline
} derive(Eq, @debug.Debug)
///|
/// A transport adapter supplied by the host application.
pub(all) enum TransportError {
Failed(String)
} derive(Eq)
///|
pub impl @debug.Debug for TransportError with fn to_repr(_self) {
@debug.Repr::Repr("transport failed")
}
///|
/// Errors returned by the record/replay session.
pub(all) enum SessionError {
ReplayMiss(Array[MatchCandidate])
TransportFailed(TransportError)
RedactionFailed(RedactionError)
IncompleteReplay(Int)
InvalidMode
} derive(Eq, @debug.Debug)
///|
/// Render a safe summary without copying transport messages or secrets.
pub fn SessionError::summary(self : SessionError) -> String {
match self {
ReplayMiss(candidates) =>
"replay miss: " + candidates.length().to_string() + " candidates"
TransportFailed(_) => "transport failed"
RedactionFailed(_) => "redaction failed"
IncompleteReplay(remaining) =>
"replay incomplete: " + remaining.to_string() + " interactions remain"
InvalidMode => "operation requires Record mode"
}
}
///|
/// Stateful record/replay session. Replay never invokes its transport.
pub struct Session {
priv cassette : Cassette
priv config : MatchConfig
priv redaction : RedactionPolicy
priv mode : SessionMode
priv consumed : Array[Bool]
}
///|
pub impl @debug.Debug for Session with fn to_repr(self) {
@debug.Repr::Repr(
"Session(remaining=" + self.remaining_interactions().to_string() + ")",
)
}
///|
pub fn Session::mode(self : Session) -> SessionMode {
self.mode
}
///|
/// Create a session with an explicit matching policy.
pub fn Session::new(
cassette : Cassette,
mode : SessionMode,
config : MatchConfig,
) -> Session {
Session::new_with_redaction(
cassette,
mode,
config,
RedactionConfig::default(),
)
}
///|
/// Create a session with explicit matching and redaction policies.
pub fn Session::new_with_redaction(
cassette : Cassette,
mode : SessionMode,
config : MatchConfig,
redaction : RedactionConfig,
) -> Session {
Session::new_with_redaction_policy(
cassette,
mode,
config,
RedactionPolicy::symmetric(redaction),
)
}
///|
/// Create a session whose request and response redaction rules are independent.
pub fn Session::new_with_redaction_policy(
cassette : Cassette,
mode : SessionMode,
config : MatchConfig,
redaction : RedactionPolicy,
) -> Session {
let count = cassette.interactions.length()
{
cassette: copy_cassette(cassette),
config: copy_match_config(config),
redaction: copy_redaction_policy(redaction),
mode,
consumed: Array::make(count, false),
}
}
///|
/// Create a session using the conservative default matching policy.
pub fn Session::with_defaults(
cassette : Cassette,
mode : SessionMode,
) -> Session {
Session::new(cassette, mode, MatchConfig::default())
}
///|
/// Send one request through the selected mode.
pub fn Session::send(
self : Session,
request : Request,
transport : (Request) -> Result[Response, TransportError],
) -> Result[Response, SessionError] {
match self.mode {
Record =>
match transport(request) {
Ok(response) => self.record_response(request, response)
Err(_) =>
Err(TransportFailed(TransportError::Failed("transport failed")))
}
Replay | StrictOffline => self.replay(request)
}
}
///|
/// Persist one response obtained by an external, possibly asynchronous
/// transport. The response returned to the caller remains unredacted.
pub fn Session::record_response(
self : Session,
request : Request,
response : Response,
) -> Result[Response, SessionError] {
if self.mode != Record {
return Err(InvalidMode)
}
let interaction : Interaction = { request, response, }
match redact_interaction_with_policy(interaction, self.redaction) {
Ok(safe_interaction) => {
self.cassette.interactions.push(safe_interaction)
self.consumed.push(false)
Ok(response)
}
Err(error) => Err(RedactionFailed(error))
}
}
///|
/// Replay one request without accepting a transport callback. This is useful
/// to platform adapters that need an asynchronous HTTP layer.
pub fn Session::replay(
self : Session,
request : Request,
) -> Result[Response, SessionError] {
match redact_request(request, self.redaction.request) {
Err(error) => Err(RedactionFailed(error))
Ok(safe_request) =>
match
match_request(safe_request, self.cassette, self.config, self.consumed) {
Matched(index) => {
self.consumed[index] = true
Ok(copy_response(self.cassette.interactions[index].response))
}
NoMatch(candidates) => Err(ReplayMiss(candidates))
}
}
}
///|
/// Explain why a request would miss the current cassette.
pub fn Session::diagnose(self : Session, request : Request) -> MismatchReport {
diagnose_mismatch_with_redaction(
request,
self.cassette,
self.config,
self.consumed,
self.redaction.request,
)
}
///|
/// Return the cassette, including interactions recorded so far.
pub fn Session::cassette(self : Session) -> Cassette {
copy_cassette(self.cassette)
}
///|
/// Number of replay entries that have not been consumed yet.
pub fn Session::remaining_interactions(self : Session) -> Int {
let mut remaining = 0
for index in 0.. Result[Unit, SessionError] {
let remaining = self.remaining_interactions()
if remaining == 0 {
Ok(())
} else {
Err(IncompleteReplay(remaining))
}
}
///|
fn copy_header(header : Header) -> Header {
{ name: header.name + "", value: header.value + "", }
}
///|
fn copy_headers(headers : Array[Header]) -> Array[Header] {
let copied : Array[Header] = []
for header in headers {
copied.push(copy_header(header))
}
copied
}
///|
fn copy_body(body : Body) -> Body {
match body {
Empty => Empty
Text(value) => Text(value + "")
Base64(value) => Base64(value + "")
}
}
///|
fn copy_request(request : Request) -> Request {
{
method: request.method + "",
url: request.url + "",
headers: copy_headers(request.headers),
body: copy_body(request.body),
}
}
///|
fn copy_response(response : Response) -> Response {
{
status: response.status,
headers: copy_headers(response.headers),
body: copy_body(response.body),
}
}
///|
fn copy_cassette(cassette : Cassette) -> Cassette {
let interactions : Array[Interaction] = []
for interaction in cassette.interactions {
interactions.push({
request: copy_request(interaction.request),
response: copy_response(interaction.response),
})
}
{ format_version: cassette.format_version, interactions, }
}
///|
fn copy_match_config(config : MatchConfig) -> MatchConfig {
{
include_headers: config.include_headers.map(value => value + ""),
ignore_headers: config.ignore_headers.map(value => value + ""),
body_mode: config.body_mode,
}
}
///|
fn copy_redaction_config(config : RedactionConfig) -> RedactionConfig {
{
headers: config.headers.map(value => value + ""),
query_parameters: config.query_parameters.map(value => value + ""),
json_paths: config.json_paths.map(value => value + ""),
optional_json_paths: config.optional_json_paths.map(value => value + ""),
}
}
///|
fn copy_redaction_policy(policy : RedactionPolicy) -> RedactionPolicy {
{
request: copy_redaction_config(policy.request),
response: copy_redaction_config(policy.response),
}
}