use std::{
collections::HashMap,
path::PathBuf,
};
use http::Method;
use crate::{
parser::{
context,
protocol::{
SessionRequestTarget, ensure_session_protocol_compatible,
infer_request_protocol, infer_ws_send_kind,
is_graphql_document_content_type, remember_session_target,
resolve_request_target, validate_graphql_variables_json,
validate_mcp_object_json,
},
spans::template_error_to_span,
variables::{assign_request_variable, SecretValueCache},
},
request::{
FragmentInclude, GraphqlTemplateOperation,
HttpTemplateOperation, McpInitializeTemplate, McpTemplateCall,
McpTemplateOperation, McpToolCallTemplate, RedactionRules,
OAuthProfile, RequestProtocol, RequestTemplate, RequestTemplateOperation,
RequestReliabilityPolicyTemplate, SseTemplateAction,
SseTemplateOperation, VariableStore, WsSendKindTemplate, WsTemplateAction,
WsTemplateOperation,
},
schema::SchemaRegistry,
};
use super::{
Rule,
ScannedRequest,
ValidatedMcpCallKind,
ValidatedSseActionKind,
ValidatedWsActionKind,
parse_assertions,
parse_response_captures,
scan_request_block,
validate_graphql_request,
validate_mcp_call,
validate_protocol_directives,
validate_sse_action,
validate_ws_action,
};
pub(in crate::parser) fn parse_request_template(
pair: pest::iterators::Pair<Rule>,
collection_store: &VariableStore,
oauth_profiles: &HashMap<String, OAuthProfile>,
global_headers: &HashMap<String, String>,
global_queries: &[(String, String)],
global_cookies: &HashMap<String, String>,
global_callbacks: &[String],
global_reliability: &RequestReliabilityPolicyTemplate,
schema_registry: &SchemaRegistry,
redaction_rules: &RedactionRules,
working_dir: &PathBuf,
secret_cache: &mut SecretValueCache,
session_targets: &mut HashMap<String, SessionRequestTarget>,
) -> Result<RequestTemplate, pest::error::Error<Rule>> {
let scanned_request = scan_request_block(
pair,
collection_store,
global_callbacks.to_vec(),
|store, key, raw_value, span| {
assign_request_variable(store, key, raw_value, working_dir, secret_cache)
.map_err(|err| template_error_to_span(err, span))
},
)?;
let has_graphql_directives = scanned_request.has_graphql_directives();
let has_mcp_directives = scanned_request.has_mcp_directives();
let has_ws_directives = scanned_request.has_ws_directives();
let has_sse_directives = scanned_request.has_sse_directives();
let ScannedRequest {
request_span,
store,
description_raw,
method,
url_raw,
header_raw,
query_raw,
cookie_raw,
form_text_raw,
form_file_raw,
protocol_raw,
session_name_raw,
auth_profile_raw,
operation_name_raw,
graphql_variables_raw,
mcp_call_raw,
mcp_tool_raw,
mcp_arguments_raw,
ws_send_raw,
sse_receive_seen,
sse_within_raw,
timeout_raw,
poll_until_raw,
poll_every_raw,
mcp_protocol_version_raw,
mcp_client_name_raw,
mcp_client_version_raw,
mcp_capabilities_raw,
body_raw,
body_content_type_raw,
callback_src_raw,
dependencies,
dependency_raw: _,
response_capture_raw,
assertion_raw,
fragment_includes_raw,
fragment_guard_raw: _,
} = scanned_request;
let session_name_raw = session_name_raw.map(|(raw, _)| raw);
let auth_profile_name = auth_profile_raw.as_ref().map(|(raw, _)| raw.clone());
let operation_name_raw = operation_name_raw.map(|(raw, _)| raw);
let graphql_variables_raw = graphql_variables_raw.map(|(raw, _)| raw);
let mcp_call_raw = mcp_call_raw.map(|(raw, _)| raw);
let mcp_tool_raw = mcp_tool_raw.map(|(raw, _)| raw);
let mcp_arguments_raw = mcp_arguments_raw.map(|(raw, _)| raw);
let ws_send_raw = ws_send_raw.map(|(raw, _)| raw);
let sse_within_raw = sse_within_raw.map(|(raw, _)| raw);
let timeout_raw = timeout_raw.map(|(raw, _)| raw);
let poll_until_raw = poll_until_raw.map(|(raw, _)| raw);
let poll_every_raw = poll_every_raw.map(|(raw, _)| raw);
let mcp_protocol_version_raw = mcp_protocol_version_raw.map(|(raw, _)| raw);
let mcp_client_name_raw = mcp_client_name_raw.map(|(raw, _)| raw);
let mcp_client_version_raw = mcp_client_version_raw.map(|(raw, _)| raw);
let mcp_capabilities_raw = mcp_capabilities_raw.map(|(raw, _)| raw);
let body_raw = body_raw.map(|(raw, _)| raw);
let body_content_type_raw = body_content_type_raw.map(|(raw, _)| raw);
let scalar_map = store.clone_scalars();
let description_template = if description_raw.trim().is_empty() {
"[No Description]".to_string()
} else {
description_raw.trim().to_string()
};
let group_key = if description_raw.trim().is_empty() {
"[No Description]".to_string()
} else {
context::resolve_with_context(description_raw.trim(), &scalar_map)
};
let mut headers = HashMap::new();
for (key, value) in global_headers {
headers.insert(
key.clone(),
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
for (key, value) in header_raw {
headers.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let local_query_keys = query_raw
.iter()
.map(|(key, _)| key.as_str())
.collect::<std::collections::HashSet<_>>();
let mut query_params = Vec::new();
for (key, value) in global_queries {
if local_query_keys.contains(key.as_str()) {
continue;
}
query_params.push((
key.clone(),
context::inject_from_variable(value.as_str(), &scalar_map),
));
}
for (key, value) in query_raw {
query_params.push((
key,
context::inject_from_variable(value.as_str(), &scalar_map),
));
}
let mut cookies = HashMap::new();
for (key, value) in global_cookies {
cookies.insert(
key.clone(),
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
for (key, value) in cookie_raw {
cookies.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
if !cookies.is_empty()
&& headers
.keys()
.any(|key| key.eq_ignore_ascii_case("Cookie"))
{
return Err(pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "structured '@ CookieName = ...' cookies cannot be mixed with a manual '* Cookie = ...' header on the same request".to_string(),
},
request_span.clone(),
));
}
let mut form_text = HashMap::new();
for (key, value) in form_text_raw {
form_text.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let mut form_files = HashMap::new();
for (key, value) in form_file_raw {
form_files.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let body = body_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let body_content_type =
body_content_type_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let operation_name =
operation_name_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let graphql_variables =
graphql_variables_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let session_name =
session_name_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_call =
mcp_call_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_tool =
mcp_tool_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_arguments =
mcp_arguments_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_protocol_version = mcp_protocol_version_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_client_name =
mcp_client_name_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_client_version = mcp_client_version_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let mcp_capabilities =
mcp_capabilities_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let ws_send =
ws_send_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let sse_within =
sse_within_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let reliability = RequestReliabilityPolicyTemplate {
timeout: timeout_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map))
.or_else(|| {
global_reliability.timeout.as_ref().map(|raw| {
context::inject_from_variable(raw.as_str(), &scalar_map)
})
}),
poll_until: poll_until_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map))
.or_else(|| {
global_reliability.poll_until.as_ref().map(|raw| {
context::inject_from_variable(raw.as_str(), &scalar_map)
})
}),
poll_every: poll_every_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map))
.or_else(|| {
global_reliability.poll_every.as_ref().map(|raw| {
context::inject_from_variable(raw.as_str(), &scalar_map)
})
}),
};
let protocol = infer_request_protocol(
protocol_raw
.as_ref()
.map(|(protocol, span)| (protocol.as_str(), span.clone())),
session_name.as_deref(),
has_mcp_directives,
has_ws_directives,
has_sse_directives,
session_targets,
)?;
ensure_session_protocol_compatible(
session_name.as_deref(),
protocol,
session_targets,
request_span.clone(),
)?;
if let Some((auth_profile_name, auth_span)) = auth_profile_raw.as_ref() {
if !oauth_profiles.contains_key(auth_profile_name) {
return Err(pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!(
"request references unknown OAuth profile '{}'.",
auth_profile_name
),
},
auth_span.clone(),
));
}
}
let auth_profile = auth_profile_name
.as_ref()
.and_then(|name| oauth_profiles.get(name))
.cloned();
validate_protocol_directives(
protocol,
has_graphql_directives,
has_mcp_directives,
has_ws_directives,
has_sse_directives,
request_span.clone(),
)?;
let (method, url_raw) = resolve_request_target(
method,
url_raw,
session_name.as_deref(),
protocol,
session_targets,
request_span.clone(),
)?;
let callback_src = callback_src_raw
.into_iter()
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map))
.collect();
let response_captures = parse_response_captures(response_capture_raw, &scalar_map)?;
let assertions = parse_assertions(assertion_raw, &scalar_map, schema_registry)?;
let fragment_includes = fragment_includes_raw
.into_iter()
.map(|(path, guard, index, _)| FragmentInclude {
path,
guard,
assertion_index: index,
})
.collect();
let operation = match protocol {
RequestProtocol::Http => {
RequestTemplateOperation::Http(HttpTemplateOperation {
method: method.clone(),
url: url_raw.clone(),
headers,
query_params,
cookies,
form_text,
form_files,
body,
body_content_type,
})
}
RequestProtocol::Graphql => {
validate_graphql_request(
request_span.clone(),
method.clone(),
!form_text.is_empty() || !form_files.is_empty(),
body.is_some(),
body_content_type
.as_deref()
.map(is_graphql_document_content_type)
.unwrap_or(false),
request_span.clone(),
|| {
if let Some(raw) = &graphql_variables {
validate_graphql_variables_json(raw.as_str(), request_span.clone())?;
}
Ok(())
},
)?;
let document = body.expect("validated GraphQL document block");
RequestTemplateOperation::Graphql(GraphqlTemplateOperation {
http: HttpTemplateOperation {
method: method.clone(),
url: url_raw.clone(),
headers,
query_params,
cookies,
form_text,
form_files,
body: None,
body_content_type: None,
},
operation_name,
document,
variables_json: graphql_variables,
})
}
RequestProtocol::Mcp => {
if method != Method::POST {
return Err(pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "MCP-over-HTTP requests currently require POST".to_string(),
},
request_span.clone(),
));
}
if !form_text.is_empty()
|| !form_files.is_empty()
|| body.is_some()
|| body_content_type.is_some()
{
return Err(pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "MCP-over-HTTP requests do not support explicit body, content type, or form fields".to_string(),
},
request_span.clone(),
));
}
let call = match validate_mcp_call(
request_span.clone(),
mcp_call.as_deref(),
mcp_tool.is_some(),
mcp_arguments
.as_deref()
.map(|raw| (raw, request_span.clone())),
mcp_protocol_version.is_some(),
mcp_client_name.is_some(),
mcp_client_version.is_some(),
mcp_capabilities
.as_deref()
.map(|raw| (raw, request_span.clone())),
request_span.clone(),
|raw, field, span| validate_mcp_object_json(raw, field, span),
)? {
ValidatedMcpCallKind::Initialize => {
McpTemplateCall::Initialize(McpInitializeTemplate {
protocol_version: mcp_protocol_version,
client_name: mcp_client_name,
client_version: mcp_client_version,
capabilities_json: mcp_capabilities,
})
}
ValidatedMcpCallKind::ToolsList => McpTemplateCall::ToolsList,
ValidatedMcpCallKind::ResourcesList => McpTemplateCall::ResourcesList,
ValidatedMcpCallKind::ToolsCall => {
McpTemplateCall::ToolsCall(McpToolCallTemplate {
tool_name: mcp_tool.expect("validated MCP tool directive"),
arguments_json: mcp_arguments,
})
}
};
RequestTemplateOperation::Mcp(McpTemplateOperation {
http: HttpTemplateOperation {
method: method.clone(),
url: url_raw.clone(),
headers,
query_params,
cookies,
form_text,
form_files,
body: None,
body_content_type: None,
},
call,
})
}
RequestProtocol::Sse => {
let action = match validate_sse_action(
request_span.clone(),
session_name.is_some(),
method.clone(),
(!form_text.is_empty()
|| !form_files.is_empty()
|| body.is_some()
|| body_content_type.is_some())
.then_some((
"SSE requests do not support explicit body, content type, or form fields",
request_span.clone(),
)),
sse_receive_seen,
sse_within
.as_deref()
.map(|within| (within, request_span.clone())),
request_span.clone(),
)? {
ValidatedSseActionKind::Open => SseTemplateAction::Open,
ValidatedSseActionKind::Receive => SseTemplateAction::Receive {
within: sse_within.expect("validated SSE within directive"),
},
};
RequestTemplateOperation::Sse(SseTemplateOperation {
http: HttpTemplateOperation {
method: method.clone(),
url: url_raw.clone(),
headers,
query_params,
cookies,
form_text,
form_files,
body: None,
body_content_type: None,
},
action,
})
}
RequestProtocol::Ws => {
let send_kind = infer_ws_send_kind(
ws_send.as_deref(),
body_content_type.as_deref(),
body.is_some(),
)
.map_err(|message| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError { message },
request_span.clone(),
)
})?;
let action_kind = validate_ws_action(
request_span.clone(),
session_name.is_some(),
method.clone(),
(!form_text.is_empty() || !form_files.is_empty()).then_some((
"WebSocket requests do not support form fields",
request_span.clone(),
)),
sse_receive_seen,
ws_send.is_some(),
request_span.clone(),
send_kind,
body.is_some(),
request_span.clone(),
(body.is_some() || body_content_type.is_some()).then_some((
if sse_receive_seen {
"WebSocket receive steps do not support explicit body or content type blocks"
} else {
"WebSocket open steps do not support explicit body or content type blocks"
},
request_span.clone(),
)),
sse_within
.as_deref()
.map(|within| (within, request_span.clone())),
)?;
let action = match action_kind {
ValidatedWsActionKind::Receive => WsTemplateAction::Receive {
within: sse_within.expect("validated WS receive within directive"),
},
ValidatedWsActionKind::Send | ValidatedWsActionKind::Exchange => {
let kind = send_kind.expect("validated WS send kind");
let payload = body.expect("validated WS send payload");
if matches!(kind, WsSendKindTemplate::Json) {
serde_json::from_str::<serde_json::Value>(payload.as_str()).map_err(|err| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!("invalid WebSocket JSON payload: {}", err),
},
request_span.clone(),
)
})?;
}
if matches!(action_kind, ValidatedWsActionKind::Exchange) {
WsTemplateAction::Exchange {
kind,
payload,
within: sse_within.expect("validated WS exchange within directive"),
}
} else {
WsTemplateAction::Send { kind, payload }
}
}
ValidatedWsActionKind::Open => WsTemplateAction::Open,
};
RequestTemplateOperation::Ws(WsTemplateOperation {
http: HttpTemplateOperation {
method: method.clone(),
url: url_raw.clone(),
headers,
query_params,
cookies,
form_text,
form_files,
body: None,
body_content_type: None,
},
action,
})
}
};
remember_session_target(
session_targets,
session_name.as_deref(),
protocol,
&method,
&url_raw,
);
Ok(RequestTemplate {
group_key,
description_template,
operation,
reliability,
session_name,
auth_profile_name,
auth_profile,
callback_src,
response_captures,
assertions,
fragment_includes,
declared_dependencies: dependencies,
variable_store: store,
redaction_rules: redaction_rules.clone(),
working_dir: working_dir.clone(),
})
}