use std::{
collections::{BTreeSet, HashMap, HashSet},
path::PathBuf,
};
use http::Method;
use crate::{
request::{
OAuthProfile, RedactionRules, RequestProtocol, VariableStore,
WsSendKindTemplate,
},
schema::{RegistryEntry, SchemaRegistry},
};
use super::{
Rule, context,
declarations::{
DeclarationSpanIndex, parse_request_collection, register_declaration,
remember_declaration_span, validate_schema_registry,
},
legacy_header::normalize_legacy_collection_header,
preprocessor,
protocol::{
SessionRequestTarget, ensure_session_protocol_compatible,
infer_request_protocol, infer_syntax_ws_send_kind,
is_graphql_document_content_type, remember_session_target,
resolve_request_target, syntax_protocol_context_json,
validate_graphql_variables_json, validate_mcp_object_json,
},
oauth::parse_oauth_block,
request::{
ScannedRequest, parse_assertions, parse_response_captures,
scan_request_block, validate_fragment_guards,
validate_graphql_request, validate_mcp_call,
validate_protocol_directives, validate_sse_action,
validate_ws_action,
},
redaction::apply_redaction_directive,
spans::{preprocess_error_to_span, template_error_to_span},
variables::{
assign_environment_variable_for_validation,
assign_global_variable_for_validation,
assign_request_variable_for_validation,
},
};
#[derive(Debug, Clone)]
pub struct SyntaxRequestSummary {
pub index: usize,
pub description: String,
pub method: String,
pub url: String,
pub protocol: String,
pub protocol_context: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct SyntaxSummary {
pub name: String,
pub description: String,
pub available_environments: Vec<String>,
pub requests: Vec<SyntaxRequestSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyntaxPositionSummary {
pub line: usize,
pub character: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyntaxRangeSummary {
pub start: SyntaxPositionSummary,
pub end: SyntaxPositionSummary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyntaxDeclarationSummary {
pub kind: String,
pub name: String,
pub range: SyntaxRangeSummary,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyntaxFragmentIncludeSummary {
pub path: String,
pub guard: Option<String>,
pub assertion_index: usize,
pub range: SyntaxRangeSummary,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SyntaxSymbolTable {
pub requests: Vec<String>,
pub schemas: Vec<String>,
pub scalars: Vec<String>,
pub environments: Vec<String>,
pub oauth_profiles: Vec<String>,
pub sessions: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SyntaxInspectRequestSummary {
pub index: usize,
pub description: String,
pub method: String,
pub url: String,
pub protocol: String,
pub protocol_context: Option<serde_json::Value>,
pub dependencies: Vec<String>,
pub fragment_includes: Vec<SyntaxFragmentIncludeSummary>,
pub session_name: Option<String>,
pub auth_profile: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SyntaxInspectResult {
pub summary: SyntaxSummary,
pub declarations: Vec<SyntaxDeclarationSummary>,
pub requests: Vec<SyntaxInspectRequestSummary>,
pub symbols: SyntaxSymbolTable,
}
#[derive(Debug, Clone)]
struct ValidatedSyntaxData {
summary: SyntaxSummary,
scalars: Vec<String>,
schemas: Vec<String>,
oauth_profiles: Vec<String>,
sessions: Vec<String>,
}
#[derive(Debug, Clone)]
struct LocalInspectRequestData {
dependencies: Vec<String>,
fragment_includes: Vec<SyntaxFragmentIncludeSummary>,
session_name: Option<String>,
auth_profile: Option<String>,
}
#[derive(Debug, Clone)]
struct LocalInspectData {
declarations: Vec<SyntaxDeclarationSummary>,
requests: Vec<LocalInspectRequestData>,
scalars: Vec<String>,
schemas: Vec<String>,
oauth_profiles: Vec<String>,
sessions: Vec<String>,
}
#[derive(Debug, Clone)]
struct ValidatedRequestData {
summary: SyntaxRequestSummary,
dependency_references: Vec<RequestDependencyReference>,
}
#[derive(Debug, Clone)]
struct RequestDependencyReference {
owner: String,
dependency: String,
span: SyntaxSourceSpan,
}
#[derive(Debug, Clone, Copy)]
struct SyntaxSourceSpan {
start: usize,
end: usize,
}
impl SyntaxSourceSpan {
fn from_pest_span(span: pest::Span<'_>) -> Self {
Self {
start: span.start(),
end: span.end(),
}
}
fn into_pest_span<'a>(self, source: &'a str) -> pest::Span<'a> {
pest::Span::new(source, self.start, self.end).unwrap()
}
}
pub fn inspect_collection_syntax(
input: &str,
working_dir: PathBuf,
) -> Result<SyntaxSummary, pest::error::Error<Rule>> {
Ok(inspect_collection_syntax_validated(input, working_dir)?.summary)
}
pub fn inspect_collection_editor_support(
input: &str,
working_dir: PathBuf,
) -> Result<SyntaxInspectResult, pest::error::Error<Rule>> {
let validated = inspect_collection_syntax_validated(input, working_dir)?;
let local = inspect_local_authoring_surface(input)?;
if validated.summary.requests.len() != local.requests.len() {
return Err(pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "request inspection summary did not align with local request scan"
.to_string(),
},
pest::Span::new(input, 0, input.len()).unwrap(),
));
}
let requests = validated
.summary
.requests
.iter()
.cloned()
.zip(local.requests)
.map(|(summary, local_request)| SyntaxInspectRequestSummary {
index: summary.index,
description: summary.description,
method: summary.method,
url: summary.url,
protocol: summary.protocol,
protocol_context: summary.protocol_context,
dependencies: local_request.dependencies,
fragment_includes: local_request.fragment_includes,
session_name: local_request.session_name,
auth_profile: local_request.auth_profile,
})
.collect();
Ok(SyntaxInspectResult {
summary: validated.summary.clone(),
declarations: local.declarations,
requests,
symbols: SyntaxSymbolTable {
requests: validated
.summary
.requests
.iter()
.map(|request| request.description.clone())
.collect(),
schemas: merge_symbol_names(validated.schemas, local.schemas),
scalars: merge_symbol_names(validated.scalars, local.scalars),
environments: validated.summary.available_environments.clone(),
oauth_profiles: merge_symbol_names(
validated.oauth_profiles,
local.oauth_profiles,
),
sessions: merge_symbol_names(validated.sessions, local.sessions),
},
})
}
fn inspect_collection_syntax_validated(
input: &str,
working_dir: PathBuf,
) -> Result<ValidatedSyntaxData, pest::error::Error<Rule>> {
let preprocessed = preprocessor::preprocess(input, &working_dir)
.map_err(|err| preprocess_error_to_span(err, pest::Span::new(input, 0, input.len()).unwrap()))?;
let preprocessed = normalize_legacy_collection_header(preprocessed.as_str());
let collection = parse_request_collection(preprocessed.as_str())?;
let mut name = String::new();
let mut description = String::new();
let mut requests = Vec::new();
let mut global_store = VariableStore::new();
let mut schema_registry = SchemaRegistry::default();
let mut declaration_spans = DeclarationSpanIndex::new();
let mut session_targets: HashMap<String, SessionRequestTarget> = HashMap::new();
let mut environment_names = HashSet::new();
let mut oauth_profiles: HashMap<String, OAuthProfile> = HashMap::new();
let mut redaction_rules = RedactionRules::default();
let mut dependency_references = Vec::new();
for pair in collection.into_inner() {
match pair.as_rule() {
Rule::collection_name => {
name = pair.as_str().trim().to_string();
}
Rule::collection_description => {
description.push_str(pair.as_str().trim());
}
Rule::variable => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().to_string();
assign_global_variable_for_validation(&mut global_store, key, value.as_str())
.map_err(|err| template_error_to_span(err, span.clone()))?;
}
Rule::environment_block => {
validate_environment_block_for_syntax(
pair,
&global_store,
&mut environment_names,
)?;
}
Rule::oauth_block => {
let span = pair.as_span();
let profile = parse_oauth_block(pair)?;
if oauth_profiles
.insert(profile.name.clone(), profile.clone())
.is_some()
{
return Err(pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!(
"Duplicate OAuth profile '{}' declared.",
profile.name
),
},
span,
));
}
}
Rule::timeout_directive | Rule::poll_until_directive | Rule::poll_every_directive => {}
Rule::header | Rule::query | Rule::callback => {}
Rule::redact_header_directive
| Rule::redact_capture_directive
| Rule::redact_body_directive => {
apply_redaction_directive(&mut redaction_rules, pair)?;
}
Rule::declaration => {
remember_declaration_span(&mut declaration_spans, &pair);
register_declaration(&mut schema_registry, pair)?;
}
Rule::requests => {
validate_schema_registry(
&schema_registry,
preprocessed.as_str(),
&declaration_spans,
)?;
for (index, request_pair) in pair.into_inner().enumerate() {
let validated_request = inspect_request_syntax(
index,
request_pair,
preprocessed.as_str(),
&global_store,
&oauth_profiles,
&schema_registry,
&mut session_targets,
)?;
dependency_references.extend(validated_request.dependency_references);
requests.push(validated_request.summary);
}
}
_ => {}
}
}
validate_request_dependencies(preprocessed.as_str(), &requests, &dependency_references)?;
let mut available_environments = environment_names.into_iter().collect::<Vec<_>>();
available_environments.sort();
let mut scalar_names = Vec::new();
let mut schema_names = Vec::new();
for (name, entry) in schema_registry.iter() {
match entry {
RegistryEntry::BuiltinScalar(_) => {}
RegistryEntry::Scalar(_) => scalar_names.push(name.to_string()),
RegistryEntry::Schema(_) => schema_names.push(name.to_string()),
}
}
let mut oauth_profile_names = oauth_profiles.into_keys().collect::<Vec<_>>();
oauth_profile_names.sort();
let mut session_names = session_targets.into_keys().collect::<Vec<_>>();
session_names.sort();
Ok(ValidatedSyntaxData {
summary: SyntaxSummary {
name,
description,
available_environments,
requests,
},
scalars: scalar_names,
schemas: schema_names,
oauth_profiles: oauth_profile_names,
sessions: session_names,
})
}
pub fn inspect_collection_syntax_tolerant(
input: &str,
working_dir: PathBuf,
) -> Option<SyntaxSummary> {
let preprocessed = preprocessor::preprocess(input, &working_dir).ok()?;
let preprocessed = normalize_legacy_collection_header(preprocessed.as_str());
Some(tolerant_syntax_summary(preprocessed.as_str()))
}
fn validate_environment_block_for_syntax(
pair: pest::iterators::Pair<Rule>,
global_store: &VariableStore,
environment_names: &mut HashSet<String>,
) -> Result<(), pest::error::Error<Rule>> {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let name = inner_pairs
.next()
.expect("environment block should include a name")
.as_str()
.trim()
.to_string();
if !environment_names.insert(name.clone()) {
return Err(pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!("Duplicate environment '{}' declared.", name),
},
span,
));
}
let mut env_store = global_store.clone();
for env_variable in inner_pairs {
let env_span = env_variable.as_span();
let mut variable_parts = env_variable.into_inner();
let key = variable_parts
.next()
.expect("environment variable should include a key")
.as_str()
.trim()
.to_string();
let value = variable_parts
.next()
.expect("environment variable should include a value")
.as_str()
.to_string();
if !env_store.scalars().contains_key(&key) {
return Err(template_error_to_span(
crate::request::TemplateError::UnknownEnvironmentVariable {
environment: name.clone(),
variable: key,
},
env_span,
));
}
assign_environment_variable_for_validation(&mut env_store, &name, key, &value)
.map_err(|err| template_error_to_span(err, env_span.clone()))?;
}
Ok(())
}
#[derive(Debug, Clone)]
struct TolerantSessionTarget {
protocol: RequestProtocol,
method: String,
url: String,
}
fn tolerant_syntax_summary(source: &str) -> SyntaxSummary {
let (preamble_lines, request_blocks) = tolerant_source_sections(source);
let (name, description, available_environments) = tolerant_preamble_summary(&preamble_lines);
let mut session_targets = HashMap::new();
let requests = request_blocks
.iter()
.enumerate()
.filter_map(|(index, block)| {
tolerant_request_summary(index, block, &mut session_targets)
})
.collect();
SyntaxSummary {
name,
description,
available_environments,
requests,
}
}
fn inspect_local_authoring_surface(
input: &str,
) -> Result<LocalInspectData, pest::error::Error<Rule>> {
let normalized = normalize_legacy_collection_header(input);
let collection = parse_request_collection(normalized.as_str())?;
let mut global_store = VariableStore::new();
let mut declarations = Vec::new();
let mut scalars = BTreeSet::new();
let mut schemas = BTreeSet::new();
let mut oauth_profiles = BTreeSet::new();
let mut sessions = BTreeSet::new();
let mut requests = Vec::new();
for pair in collection.into_inner() {
match pair.as_rule() {
Rule::variable => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().to_string();
assign_global_variable_for_validation(&mut global_store, key, value.as_str())
.map_err(|err| template_error_to_span(err, span.clone()))?;
}
Rule::oauth_block => {
let profile = parse_oauth_block(pair)?;
oauth_profiles.insert(profile.name);
}
Rule::declaration => {
let inner = pair.into_inner().next().unwrap();
let kind = match inner.as_rule() {
Rule::scalar_declaration => "scalar",
Rule::schema_object_declaration | Rule::schema_array_declaration => {
"schema"
}
_ => unreachable!("unexpected declaration rule: {:?}", inner.as_rule()),
};
let name = inner.clone().into_inner().next().unwrap().as_str().to_string();
if kind == "scalar" {
scalars.insert(name.clone());
} else {
schemas.insert(name.clone());
}
declarations.push(SyntaxDeclarationSummary {
kind: kind.to_string(),
name,
range: range_from_span(inner.as_span()),
});
}
Rule::requests => {
for request_pair in pair.into_inner() {
let request = inspect_request_authoring(request_pair, &global_store)?;
if let Some(session_name) = request.session_name.clone() {
sessions.insert(session_name);
}
requests.push(request);
}
}
_ => {}
}
}
Ok(LocalInspectData {
declarations,
requests,
scalars: scalars.into_iter().collect(),
schemas: schemas.into_iter().collect(),
oauth_profiles: oauth_profiles.into_iter().collect(),
sessions: sessions.into_iter().collect(),
})
}
fn inspect_request_authoring(
pair: pest::iterators::Pair<Rule>,
collection_store: &VariableStore,
) -> Result<LocalInspectRequestData, pest::error::Error<Rule>> {
let scanned_request = scan_request_block(
pair,
collection_store,
Vec::new(),
|store, key, raw_value, span| {
assign_request_variable_for_validation(store, key, raw_value)
.map_err(|err| template_error_to_span(err, span))
},
)?;
let ScannedRequest {
store,
session_name_raw,
auth_profile_raw,
dependencies,
fragment_includes_raw,
..
} = scanned_request;
let scalar_map = store.clone_scalars();
let session_name = session_name_raw
.as_ref()
.map(|(raw, _)| context::inject_from_variable(raw.as_str(), &scalar_map));
Ok(LocalInspectRequestData {
dependencies,
fragment_includes: fragment_includes_raw
.into_iter()
.map(|(path, guard, assertion_index, span)| SyntaxFragmentIncludeSummary {
path,
guard,
assertion_index,
range: range_from_span(span),
})
.collect(),
session_name,
auth_profile: auth_profile_raw.map(|(raw, _)| raw.trim().to_string()),
})
}
fn range_from_span(span: pest::Span<'_>) -> SyntaxRangeSummary {
let (start_line, start_character) = span.start_pos().line_col();
let (end_line, end_character) = span.end_pos().line_col();
SyntaxRangeSummary {
start: SyntaxPositionSummary {
line: start_line.saturating_sub(1),
character: start_character.saturating_sub(1),
},
end: SyntaxPositionSummary {
line: end_line.saturating_sub(1),
character: end_character.saturating_sub(1),
},
}
}
fn merge_symbol_names(left: Vec<String>, right: Vec<String>) -> Vec<String> {
left.into_iter()
.chain(right)
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn tolerant_source_sections(source: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut in_fence = false;
let mut seen_requests = false;
let mut preamble = Vec::new();
let mut current_request = Vec::new();
let mut requests = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if !in_fence && trimmed == "---" {
if seen_requests {
requests.push(std::mem::take(&mut current_request));
} else {
seen_requests = true;
}
continue;
}
if trimmed.starts_with("~~~") {
in_fence = !in_fence;
}
if seen_requests {
current_request.push(line.to_string());
} else {
preamble.push(line.to_string());
}
}
if seen_requests {
requests.push(current_request);
}
(preamble, requests)
}
fn tolerant_preamble_summary(lines: &[String]) -> (String, String, Vec<String>) {
let mut name = String::new();
let mut description = String::new();
let mut available_environments = HashSet::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some(value) = directive_value(trimmed, "name") {
if name.is_empty() {
name = value.to_string();
}
continue;
}
if let Some(value) = directive_value(trimmed, "description") {
if !description.is_empty() {
description.push(' ');
}
description.push_str(value);
continue;
}
if let Some(remainder) = trimmed.strip_prefix("env ") {
if let Some(environment_name) = leading_identifier(remainder) {
available_environments.insert(environment_name.to_string());
}
}
}
let mut environments = available_environments.into_iter().collect::<Vec<_>>();
environments.sort();
(name, description, environments)
}
fn tolerant_request_summary(
index: usize,
lines: &[String],
session_targets: &mut HashMap<String, TolerantSessionTarget>,
) -> Option<SyntaxRequestSummary> {
let mut in_fence = false;
let mut description = None;
let mut protocol_raw = None;
let mut session_name = None;
let mut auth_profile_name = None;
let mut operation_name = None;
let mut graphql_variables = None;
let mut mcp_call = None;
let mut mcp_tool = None;
let mut mcp_arguments = None;
let mut mcp_protocol_version = None;
let mut mcp_client_name = None;
let mut mcp_client_version = None;
let mut mcp_capabilities = None;
let mut receive_seen = false;
let mut within = None;
let mut ws_send_kind = None;
let mut method = None;
let mut url = None;
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
if let Some(value) = directive_value(trimmed, "protocol") {
protocol_raw = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "session") {
session_name = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "auth") {
auth_profile_name = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "operation") {
operation_name = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "variables") {
graphql_variables = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "call") {
mcp_call = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "tool") {
mcp_tool = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "arguments") {
mcp_arguments = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "protocol_version") {
mcp_protocol_version = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "client_name") {
mcp_client_name = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "client_version") {
mcp_client_version = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "capabilities") {
mcp_capabilities = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "within") {
within = Some(value.to_string());
continue;
}
if let Some(value) = directive_value(trimmed, "send") {
ws_send_kind = tolerant_ws_send_kind(value);
continue;
}
if trimmed == "receive" {
receive_seen = true;
continue;
}
if let Some((request_method, request_url)) = tolerant_request_target(trimmed) {
method = Some(request_method.to_string());
url = Some(request_url.to_string());
continue;
}
if description.is_none() && !looks_like_tolerant_request_metadata(trimmed) {
description = Some(trimmed.to_string());
}
}
let description = description.unwrap_or_else(|| "[No Description]".to_string());
let protocol = tolerant_request_protocol(
protocol_raw.as_deref(),
session_name.as_deref(),
mcp_call.is_some(),
operation_name.is_some() || graphql_variables.is_some(),
receive_seen,
ws_send_kind,
session_targets,
);
let (method, url) = match (method, url, session_name.as_deref()) {
(Some(method), Some(url), _) => (method, url),
(None, None, Some(session_name)) => {
let inherited = session_targets.get(session_name)?;
(inherited.method.clone(), inherited.url.clone())
}
_ => return None,
};
let protocol_context = syntax_protocol_context_json(
protocol,
session_name.as_deref(),
auth_profile_name.as_deref(),
operation_name.as_deref(),
graphql_variables.as_deref(),
mcp_call.as_deref(),
mcp_tool.as_deref(),
mcp_arguments.as_deref(),
mcp_protocol_version.as_deref(),
mcp_client_name.as_deref(),
mcp_client_version.as_deref(),
mcp_capabilities.as_deref(),
receive_seen,
ws_send_kind,
within.as_deref(),
);
if let Some(session_name) = session_name {
session_targets.insert(
session_name,
TolerantSessionTarget {
protocol,
method: method.clone(),
url: url.clone(),
},
);
}
Some(SyntaxRequestSummary {
index,
description,
method,
url,
protocol: protocol.as_str().to_string(),
protocol_context,
})
}
fn directive_value<'a>(trimmed: &'a str, key: &str) -> Option<&'a str> {
trimmed
.strip_prefix(key)
.and_then(|value| value.trim_start().strip_prefix('='))
.map(str::trim)
}
fn leading_identifier(raw: &str) -> Option<&str> {
let trimmed = raw.trim_start();
let identifier_len = trimmed
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.count();
(identifier_len > 0).then_some(&trimmed[..identifier_len])
}
fn tolerant_request_target(trimmed: &str) -> Option<(&str, &str)> {
const METHODS: &[&str] = &["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
let (method, url) = trimmed.split_once(char::is_whitespace)?;
METHODS
.iter()
.find(|candidate| **candidate == method)
.map(|_| (method, url.trim()))
}
fn looks_like_tolerant_request_metadata(trimmed: &str) -> bool {
trimmed.starts_with('*')
|| trimmed.starts_with('?')
|| trimmed.starts_with('~')
|| trimmed.starts_with('^')
|| trimmed.starts_with('&')
|| trimmed.starts_with('!')
|| trimmed.starts_with('>')
|| trimmed.starts_with("<<")
|| trimmed.starts_with('#')
|| directive_value(trimmed, "protocol").is_some()
|| directive_value(trimmed, "session").is_some()
|| directive_value(trimmed, "auth").is_some()
|| directive_value(trimmed, "operation").is_some()
|| directive_value(trimmed, "variables").is_some()
|| directive_value(trimmed, "call").is_some()
|| directive_value(trimmed, "tool").is_some()
|| directive_value(trimmed, "arguments").is_some()
|| directive_value(trimmed, "send").is_some()
|| directive_value(trimmed, "receive").is_some()
|| directive_value(trimmed, "within").is_some()
|| directive_value(trimmed, "timeout").is_some()
|| directive_value(trimmed, "poll_until").is_some()
|| directive_value(trimmed, "poll_every").is_some()
|| directive_value(trimmed, "protocol_version").is_some()
|| directive_value(trimmed, "client_name").is_some()
|| directive_value(trimmed, "client_version").is_some()
|| directive_value(trimmed, "capabilities").is_some()
|| trimmed == "receive"
|| tolerant_request_target(trimmed).is_some()
}
fn tolerant_ws_send_kind(value: &str) -> Option<WsSendKindTemplate> {
match value {
"text" => Some(WsSendKindTemplate::Text),
"json" => Some(WsSendKindTemplate::Json),
_ => None,
}
}
fn tolerant_request_protocol(
protocol_raw: Option<&str>,
session_name: Option<&str>,
has_mcp_directives: bool,
has_graphql_directives: bool,
receive_seen: bool,
ws_send_kind: Option<WsSendKindTemplate>,
session_targets: &HashMap<String, TolerantSessionTarget>,
) -> RequestProtocol {
if let Some(protocol_raw) = protocol_raw {
return match protocol_raw {
"graphql" => RequestProtocol::Graphql,
"mcp" => RequestProtocol::Mcp,
"sse" => RequestProtocol::Sse,
"ws" => RequestProtocol::Ws,
_ => RequestProtocol::Http,
};
}
if let Some(session_name) = session_name {
if let Some(target) = session_targets.get(session_name) {
return target.protocol;
}
}
if has_mcp_directives {
return RequestProtocol::Mcp;
}
if has_graphql_directives {
return RequestProtocol::Graphql;
}
if ws_send_kind.is_some() {
return RequestProtocol::Ws;
}
if receive_seen {
return RequestProtocol::Sse;
}
RequestProtocol::Http
}
fn inspect_request_syntax(
index: usize,
pair: pest::iterators::Pair<Rule>,
source: &str,
collection_store: &VariableStore,
oauth_profiles: &HashMap<String, OAuthProfile>,
schema_registry: &SchemaRegistry,
session_targets: &mut HashMap<String, SessionRequestTarget>,
) -> Result<ValidatedRequestData, pest::error::Error<Rule>> {
let scanned_request = scan_request_block(
pair,
collection_store,
Vec::new(),
|store, key, raw_value, span| {
assign_request_variable_for_validation(store, key, raw_value)
.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 body_seen = scanned_request.body_seen();
let ScannedRequest {
request_span,
store,
description_raw: description,
method,
url_raw: url,
header_raw: _,
query_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 scalar_map = store.clone_scalars();
let session_name = session_name_raw
.as_ref()
.map(|(raw, _)| context::inject_from_variable(raw.as_str(), &scalar_map));
let auth_profile_name = auth_profile_raw.as_ref().map(|(raw, _)| raw.as_str());
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 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(),
)?;
validate_protocol_directives(
protocol,
has_graphql_directives,
has_mcp_directives,
has_ws_directives,
has_sse_directives,
request_span.clone(),
)?;
let ws_send_kind = if matches!(protocol, RequestProtocol::Ws) {
infer_syntax_ws_send_kind(
request_span.clone(),
ws_send_raw
.as_ref()
.map(|(raw, span)| (raw.as_str(), span.clone())),
body_content_type_raw
.as_ref()
.map(|(raw, span)| (raw.as_str(), span.clone())),
body_seen,
&scalar_map,
)?
} else {
None
};
let (method, url) = resolve_request_target(
method,
url,
session_name.as_deref(),
protocol,
session_targets,
request_span.clone(),
)?;
if matches!(protocol, RequestProtocol::Graphql) {
validate_graphql_request(
request_span.clone(),
method.clone(),
!form_text_raw.is_empty() || !form_file_raw.is_empty(),
body_seen,
body_content_type_raw
.as_ref()
.map(|(value, _)| is_graphql_document_content_type(value.as_str()))
.unwrap_or(false),
body_content_type_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
|| {
if let Some((raw, span)) = graphql_variables_raw.as_ref() {
validate_graphql_variables_json(raw.as_str(), span.clone())?;
}
Ok(())
},
)?;
}
if matches!(protocol, 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 body_seen || body_content_type_raw.is_some() {
let span = body_content_type_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone());
return Err(pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "MCP-over-HTTP requests do not support explicit body or content type blocks".to_string(),
},
span,
));
}
validate_mcp_call(
request_span.clone(),
mcp_call_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_tool_raw.is_some(),
mcp_arguments_raw
.as_ref()
.map(|(value, span)| (value.as_str(), span.clone())),
mcp_protocol_version_raw.is_some(),
mcp_client_name_raw.is_some(),
mcp_client_version_raw.is_some(),
mcp_capabilities_raw
.as_ref()
.map(|(value, span)| (value.as_str(), span.clone())),
mcp_call_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
|raw, field, span| validate_mcp_object_json(raw, field, span),
)?;
}
if matches!(protocol, RequestProtocol::Sse) {
validate_sse_action(
request_span.clone(),
session_name.is_some(),
method.clone(),
(body_seen || body_content_type_raw.is_some()).then(|| {
(
"SSE requests do not support explicit body or content type blocks",
body_content_type_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
)
}),
sse_receive_seen,
sse_within_raw
.as_ref()
.map(|(within, span)| (within.as_str(), span.clone())),
sse_within_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
)?;
}
if matches!(protocol, RequestProtocol::Ws) {
validate_ws_action(
request_span.clone(),
session_name.is_some(),
method.clone(),
(!form_text_raw.is_empty() || !form_file_raw.is_empty()).then(|| {
(
"WebSocket requests do not support form fields",
request_span.clone(),
)
}),
sse_receive_seen,
ws_send_raw.is_some(),
ws_send_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
ws_send_kind,
body_seen,
body_content_type_raw
.as_ref()
.map(|(_, span)| span.clone())
.or_else(|| ws_send_raw.as_ref().map(|(_, span)| span.clone()))
.unwrap_or_else(|| request_span.clone()),
(body_seen || body_content_type_raw.is_some()).then(|| {
(
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"
},
body_content_type_raw
.as_ref()
.map(|(_, span)| span.clone())
.unwrap_or_else(|| request_span.clone()),
)
}),
sse_within_raw
.as_ref()
.map(|(within, span)| (within.as_str(), span.clone())),
)?;
validate_static_ws_json_payload(
ws_send_kind,
body_raw
.as_ref()
.map(|(raw, span)| (raw.as_str(), span.clone())),
&scalar_map,
)?;
}
let _ = parse_response_captures(response_capture_raw, &scalar_map)?;
let _ = parse_assertions(assertion_raw, &scalar_map, schema_registry)?;
validate_fragment_guards(fragment_guard_raw, &scalar_map, schema_registry)?;
let description = if description.trim().is_empty() {
"[No Description]".to_string()
} else {
description.trim().to_string()
};
let protocol_context = syntax_protocol_context_json(
protocol,
session_name.as_deref(),
auth_profile_name,
operation_name_raw.as_ref().map(|(value, _)| value.as_str()),
graphql_variables_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_call_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_tool_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_arguments_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_protocol_version_raw
.as_ref()
.map(|(value, _)| value.as_str()),
mcp_client_name_raw.as_ref().map(|(value, _)| value.as_str()),
mcp_client_version_raw
.as_ref()
.map(|(value, _)| value.as_str()),
mcp_capabilities_raw.as_ref().map(|(value, _)| value.as_str()),
sse_receive_seen,
ws_send_kind,
sse_within_raw.as_ref().map(|(value, _)| value.as_str()),
);
remember_session_target(
session_targets,
session_name.as_deref(),
protocol,
&method,
&url,
);
let dependency_references = dependency_raw
.into_iter()
.map(|(dependency, span)| {
let span = reference_span_in_pest_span(source, span.clone(), dependency.as_str())
.unwrap_or(span);
RequestDependencyReference {
owner: description.clone(),
dependency,
span: SyntaxSourceSpan::from_pest_span(span),
}
})
.collect();
Ok(ValidatedRequestData {
summary: SyntaxRequestSummary {
index,
description,
method: method.as_str().to_string(),
url,
protocol: protocol.as_str().to_string(),
protocol_context,
},
dependency_references,
})
}
fn validate_request_dependencies(
source: &str,
requests: &[SyntaxRequestSummary],
dependency_references: &[RequestDependencyReference],
) -> Result<(), pest::error::Error<Rule>> {
let request_names = requests
.iter()
.map(|request| request.description.as_str())
.collect::<HashSet<_>>();
for dependency in dependency_references {
if request_names.contains(dependency.dependency.as_str()) {
continue;
}
return Err(pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!(
"Request '{}' declares unknown dependency '{}'.",
dependency.owner, dependency.dependency
),
},
dependency.span.into_pest_span(source),
));
}
Ok(())
}
fn validate_static_ws_json_payload<'a>(
send_kind: Option<WsSendKindTemplate>,
body_raw: Option<(&'a str, pest::Span<'a>)>,
scalar_map: &HashMap<String, String>,
) -> Result<(), pest::error::Error<Rule>> {
if !matches!(send_kind, Some(WsSendKindTemplate::Json)) {
return Ok(());
}
let Some((raw, span)) = body_raw else {
return Ok(());
};
let resolved = context::inject_from_variable(raw, scalar_map);
if resolved.contains("{{") || resolved.contains("[[") {
return Ok(());
}
serde_json::from_str::<serde_json::Value>(resolved.as_str()).map_err(|err| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: format!("invalid WebSocket JSON payload: {}", err),
},
span,
)
})?;
Ok(())
}
fn reference_span_in_pest_span<'a>(
source: &'a str,
span: pest::Span<'a>,
reference: &str,
) -> Option<pest::Span<'a>> {
let scope = span.as_str();
for (offset, _) in scope.match_indices(reference) {
let before = scope[..offset].chars().next_back();
let after = scope[offset + reference.len()..].chars().next();
if !is_identifier_boundary(before) || !is_identifier_boundary(after) {
continue;
}
let start = span.start() + offset;
let end = start + reference.len();
return pest::Span::new(source, start, end);
}
None
}
fn is_identifier_boundary(value: Option<char>) -> bool {
value.is_none_or(|ch| !(ch.is_ascii_alphanumeric() || ch == '_'))
}