///|
/// Declarative actions for deterministic protocol demonstrations and adapter
/// conformance fixtures. Aliases avoid coupling scenarios to generated IDs.
pub(all) enum ScenarioAction {
Discover
CreateKnown(String, Int64, Array[MetadataEntry])
CreateDeferred(String, Array[MetadataEntry])
Inspect(String)
Append(String, Int64, Bytes, Int64?)
} derive(Debug, Eq)
///|
pub(all) struct ScenarioEvent {
index : Int
operation : String
resource_name : String?
identifier : String?
status : Int
offset : Int64?
error_code : String?
traces : Array[TraceStep]
} derive(Debug, Eq)
///|
pub(all) struct ScenarioReport {
events : Array[ScenarioEvent]
uploads : Array[UploadRecord]
succeeded : Bool
} derive(Debug, Eq)
///|
priv struct AliasBinding {
resource_name : String
identifier : String
}
///|
/// Execute every action in order. Protocol failures are captured as events and
/// later independent actions still run, making one report useful for teaching
/// both successful and rejected transitions.
pub fn run_scenario(
actions : Array[ScenarioAction],
config? : TusConfig = TusConfig::default(),
) -> Result[ScenarioReport, TusError] {
let engine = match TusEngine::new(config~) {
Err(error) => return Err(error)
Ok(engine) => engine
}
let bindings : Array[AliasBinding] = []
let events : Array[ScenarioEvent] = []
let mut succeeded = true
for index, action in actions {
let event = execute_scenario_action(index, action, engine, bindings)
if event.status >= 400 {
succeeded = false
}
events.push(event)
}
Ok({ events, uploads: engine.uploads(), succeeded, })
}
///|
fn execute_scenario_action(
index : Int,
action : ScenarioAction,
engine : TusEngine,
bindings : Array[AliasBinding],
) -> ScenarioEvent {
match action {
Discover =>
event_from_response(
index,
"OPTIONS",
None,
None,
engine.handle(options_request()),
)
CreateKnown(resource_name, length, metadata) => {
if !valid_resource_name(resource_name) ||
find_binding(bindings, resource_name) is Some(_) {
return invalid_scenario_event(
index, "CREATE", resource_name, "resource name is invalid or already bound",
)
}
let headers = version_response_headers()
.add("Upload-Length", length.to_string())
.add("Upload-Metadata", serialize_upload_metadata(metadata))
let response = engine.handle(
request("POST", engine.config.collection_path, headers~),
)
bind_created_resource(bindings, resource_name, response)
event_from_response(
index,
"POST",
Some(resource_name),
created_identifier(response),
response,
)
}
CreateDeferred(resource_name, metadata) => {
if !valid_resource_name(resource_name) ||
find_binding(bindings, resource_name) is Some(_) {
return invalid_scenario_event(
index, "CREATE-DEFERRED", resource_name, "resource name is invalid or already bound",
)
}
let headers = version_response_headers()
.add("Upload-Defer-Length", "1")
.add("Upload-Metadata", serialize_upload_metadata(metadata))
let response = engine.handle(
request("POST", engine.config.collection_path, headers~),
)
bind_created_resource(bindings, resource_name, response)
event_from_response(
index,
"POST-DEFERRED",
Some(resource_name),
created_identifier(response),
response,
)
}
Inspect(resource_name) =>
match find_binding(bindings, resource_name) {
None =>
invalid_scenario_event(
index, "HEAD", resource_name, "resource name has not been created",
)
Some(identifier) => {
let response = engine.handle(
request(
"HEAD",
resource_path(engine.config.collection_path, identifier),
headers=version_response_headers(),
),
)
event_from_response(
index,
"HEAD",
Some(resource_name),
Some(identifier),
response,
)
}
}
Append(resource_name, offset, body, declared_length) =>
match find_binding(bindings, resource_name) {
None =>
invalid_scenario_event(
index, "PATCH", resource_name, "resource name has not been created",
)
Some(identifier) => {
let headers = version_response_headers()
.add("Upload-Offset", offset.to_string())
.add("Content-Type", "application/offset+octet-stream")
.add("Content-Length", body.length().to_string())
let headers = match declared_length {
None => headers
Some(total) => headers.add("Upload-Length", total.to_string())
}
let response = engine.handle(
request(
"PATCH",
resource_path(engine.config.collection_path, identifier),
headers~,
body~,
),
)
event_from_response(
index,
"PATCH",
Some(resource_name),
Some(identifier),
response,
)
}
}
}
}
///|
fn event_from_response(
index : Int,
operation : String,
resource_name : String?,
identifier : String?,
response : TusResponse,
) -> ScenarioEvent {
{
index,
operation,
resource_name,
identifier,
status: response.status,
offset: response_offset(response),
error_code: response_error_code(response),
traces: response.trace.copy(),
}
}
///|
fn invalid_scenario_event(
index : Int,
operation : String,
resource_name : String,
message : String,
) -> ScenarioEvent {
let error = tus_error(InvalidScenario, "TUS_SCENARIO_INVALID", message)
{
index,
operation,
resource_name: Some(resource_name),
identifier: None,
status: error.status,
offset: None,
error_code: Some(error.code),
traces: [],
}
}
///|
fn bind_created_resource(
bindings : Array[AliasBinding],
resource_name : String,
response : TusResponse,
) -> Unit {
match created_identifier(response) {
None => ()
Some(identifier) => bindings.push({ resource_name, identifier, })
}
}
///|
fn created_identifier(response : TusResponse) -> String? {
if response.status != 201 {
return None
}
match response.headers.first("location") {
None => None
Some(location) => {
let mut slash = -1
for index, character in location {
if character == '/' {
slash = index
}
}
if slash < 0 || slash + 1 >= location.length() {
None
} else {
Some(location[slash + 1:].to_owned())
}
}
}
}
///|
fn find_binding(
bindings : Array[AliasBinding],
resource_name : String,
) -> String? {
for binding in bindings {
if binding.resource_name == resource_name {
return Some(binding.identifier)
}
}
None
}
///|
fn valid_resource_name(resource_name : String) -> Bool {
if resource_name.length() == 0 || resource_name.length() > 64 {
return false
}
for character in resource_name {
if !(character >= 'a' && character <= 'z') &&
!(character >= 'A' && character <= 'Z') &&
!(character >= '0' && character <= '9') &&
character != '-' &&
character != '_' {
return false
}
}
true
}