feat(import): add deterministic OpenAPI normalized IR
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

This commit is contained in:
2026-08-29 04:15:37 +03:00
parent bc03c33387
commit 6c2a3712d8
22 changed files with 6051 additions and 352 deletions
@@ -0,0 +1,887 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::rest::model::{
CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition, CoverageEntry,
ImportSourcePreview, NORMALIZER_VERSION, NormalizedIr, NormalizedOperation, NormalizedPath,
NormalizedReference, NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION,
SourceLocation,
};
use super::ImportParseError;
pub(super) use super::normalize_schema::{has_reference_schema, typed_schema};
pub(super) fn validate_coverage(ir: &NormalizedIr) -> Result<(), ImportParseError> {
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
return Err(ImportParseError::InvalidDocument);
}
let mut source_nodes = BTreeMap::new();
index_source_tree(&ir.source_tree, "", &mut source_nodes)?;
let version = validate_source_contract(ir, &source_nodes)?;
let mut expected = BTreeMap::<String, ExpectedCoverage>::new();
for node in source_nodes.values() {
insert_expected(
&mut expected,
node.construct_id.clone(),
node.location.clone(),
CoverageDisposition::Mapped,
)?;
}
insert_real_expected(
&mut expected,
&source_nodes,
"document".to_owned(),
SourceLocation {
pointer: String::new(),
},
CoverageDisposition::Mapped,
)?;
for (index, location) in server_locations(ir, &source_nodes)?.into_iter().enumerate() {
insert_real_expected(
&mut expected,
&source_nodes,
format!("{version}:server:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
let expected_references = references_from_source_tree(&ir.source_tree);
if ir.unresolved_references != expected_references {
return Err(ImportParseError::InvalidDocument);
}
for reference in &ir.unresolved_references {
insert_real_expected(
&mut expected,
&source_nodes,
reference.construct_id.clone(),
reference.location.clone(),
CoverageDisposition::Mapped,
)?;
}
let expected_paths = paths_from_operations(&ir.operations, &version);
if ir.paths != expected_paths {
return Err(ImportParseError::InvalidDocument);
}
for path in &ir.paths {
insert_real_expected(
&mut expected,
&source_nodes,
path.construct_id.clone(),
path.location.clone(),
CoverageDisposition::Mapped,
)?;
}
validate_operation_order(&ir.operations)?;
for operation in &ir.operations {
collect_operation_expectations(operation, &version, &source_nodes, &mut expected)?;
}
collect_finding_only_expectations(&version, ir, &source_nodes, &mut expected)?;
let findings = ir
.findings
.iter()
.chain(
ir.operations
.iter()
.flat_map(|operation| &operation.findings),
)
.collect::<Vec<_>>();
for finding in &findings {
let Some(target) = expected.get(&finding.construct_id) else {
return Err(ImportParseError::InvalidDocument);
};
if target.location != finding.location {
return Err(ImportParseError::InvalidDocument);
}
}
if expected.iter().any(|(construct_id, target)| {
target.disposition == CoverageDisposition::Finding
&& !findings.iter().any(|finding| {
finding.construct_id == *construct_id && finding.location == target.location
})
}) {
return Err(ImportParseError::InvalidDocument);
}
let mut actual = BTreeMap::new();
for entry in &ir.coverage {
if actual
.insert(
entry.construct_id.clone(),
ExpectedCoverage {
location: entry.location.clone(),
disposition: entry.disposition.clone(),
},
)
.is_some()
{
return Err(ImportParseError::InvalidDocument);
}
}
if actual != expected {
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExpectedCoverage {
location: SourceLocation,
disposition: CoverageDisposition,
}
fn insert_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
let value = ExpectedCoverage {
location,
disposition,
};
if expected
.get(&construct_id)
.is_some_and(|existing| existing != &value)
{
return Err(ImportParseError::InvalidDocument);
}
expected.entry(construct_id).or_insert(value);
Ok(())
}
fn insert_real_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
if !source_nodes.contains_key(&location.pointer) {
return Err(ImportParseError::InvalidDocument);
}
insert_expected(expected, construct_id, location, disposition)
}
fn index_source_tree<'a>(
node: &'a CanonicalSourceNode,
expected_pointer: &str,
nodes: &mut BTreeMap<String, &'a CanonicalSourceNode>,
) -> Result<(), ImportParseError> {
if node.location.pointer != expected_pointer
|| node.construct_id != format!("source:{expected_pointer}")
|| nodes.insert(expected_pointer.to_owned(), node).is_some()
{
return Err(ImportParseError::InvalidDocument);
}
match &node.value {
CanonicalSourceValue::Array(items) => {
for (index, child) in items.iter().enumerate() {
index_source_tree(child, &format!("{expected_pointer}/{index}"), nodes)?;
}
}
CanonicalSourceValue::Object(items) => {
for (key, child) in items {
index_source_tree(
child,
&format!("{expected_pointer}/{}", escape_pointer(key)),
nodes,
)?;
}
}
_ => {}
}
Ok(())
}
fn validate_source_contract(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<String, ImportParseError> {
let version = ir
.source
.version
.as_deref()
.ok_or(ImportParseError::InvalidDocument)?;
let valid = match ir.source.format.as_str() {
"openapi" => string_at(nodes, "/openapi") == Some(version) && supported_oas(version),
"swagger" => version == "2.0" && string_at(nodes, "/swagger") == Some("2.0"),
_ => false,
};
if !valid
|| ir.metadata.title != ir.source.title
|| ir.source.title != string_at(nodes, "/info/title").unwrap_or("Imported API")
|| ir.metadata.version != string_at(nodes, "/info/version").map(ToOwned::to_owned)
|| ir.metadata.description != string_at(nodes, "/info/description").map(ToOwned::to_owned)
|| ir.base_path_candidates
!= string_at(nodes, "/basePath")
.map(|value| vec![value.to_owned()])
.unwrap_or_default()
{
return Err(ImportParseError::InvalidDocument);
}
Ok(version.to_owned())
}
fn supported_oas(version: &str) -> bool {
["3.0.", "3.1."].into_iter().any(|prefix| {
version.strip_prefix(prefix).is_some_and(|patch| {
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
})
})
}
fn string_at<'a>(
nodes: &'a BTreeMap<String, &CanonicalSourceNode>,
pointer: &str,
) -> Option<&'a str> {
match &nodes.get(pointer)?.value {
CanonicalSourceValue::String(value) => Some(value),
_ => None,
}
}
fn server_locations(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
server_locations_for_source(&ir.source, nodes)
}
fn server_locations_for_source(
source: &ImportSourcePreview,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
let (urls, locations) = if source.format == "openapi" {
openapi_servers(nodes)
} else {
swagger_servers(nodes)
};
if urls != source.servers {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations)
}
pub(super) fn server_coverage(
source: &ImportSourcePreview,
source_tree: &CanonicalSourceNode,
version: &str,
) -> Result<Vec<CoverageEntry>, ImportParseError> {
let mut nodes = BTreeMap::new();
index_source_tree(source_tree, "", &mut nodes)?;
Ok(server_locations_for_source(source, &nodes)?
.into_iter()
.enumerate()
.map(|(index, location)| CoverageEntry {
construct_id: format!("{version}:server:{index}"),
location,
disposition: CoverageDisposition::Mapped,
})
.collect())
}
pub(super) fn tag_locations(
root: &Value,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let source_tags = root
.pointer(&format!("{operation_pointer}/tags"))
.and_then(Value::as_array);
let locations = source_tags
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, value)| {
value.as_str().map(|value| {
(
value,
SourceLocation {
pointer: format!("{operation_pointer}/tags/{index}"),
},
)
})
})
.collect::<Vec<_>>();
if locations
.iter()
.map(|(value, _)| *value)
.ne(tags.iter().map(String::as_str))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn openapi_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Array(items),
..
}) = nodes.get("/servers").copied()
else {
return (Vec::new(), Vec::new());
};
items
.iter()
.filter_map(|item| {
let CanonicalSourceValue::Object(fields) = &item.value else {
return None;
};
let CanonicalSourceValue::String(url) = &fields.get("url")?.value else {
return None;
};
Some((url.trim_end_matches('/').to_owned(), item.location.clone()))
})
.unzip()
}
fn swagger_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(host) = string_at(nodes, "/host") else {
return (Vec::new(), Vec::new());
};
let base_path = string_at(nodes, "/basePath").unwrap_or("");
let schemes = nodes
.get("/schemes")
.and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(
items
.iter()
.filter_map(|item| match &item.value {
CanonicalSourceValue::String(value) => Some((value.as_str(), item)),
_ => None,
})
.collect::<Vec<_>>(),
),
_ => None,
})
.filter(|items| !items.is_empty());
let schemes = schemes.unwrap_or_else(|| {
vec![(
"https",
*nodes.get("/host").expect("host was checked above"),
)]
});
schemes
.into_iter()
.map(|(scheme, node)| {
(
format!("{scheme}://{host}{base_path}")
.trim_end_matches('/')
.to_owned(),
node.location.clone(),
)
})
.unzip()
}
fn validate_operation_order(operations: &[NormalizedOperation]) -> Result<(), ImportParseError> {
if operations
.windows(2)
.any(|pair| operation_sort_key(&pair[0]) >= operation_sort_key(&pair[1]))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
fn operation_sort_key(operation: &NormalizedOperation) -> (&str, u8, &str) {
(
&operation.path,
method_rank(operation.method),
&operation.location.pointer,
)
}
fn method_rank(method: crank_core::HttpMethod) -> u8 {
match method {
crank_core::HttpMethod::Get => 0,
crank_core::HttpMethod::Post => 1,
crank_core::HttpMethod::Put => 2,
crank_core::HttpMethod::Patch => 3,
crank_core::HttpMethod::Delete => 4,
}
}
fn method_name(method: crank_core::HttpMethod) -> &'static str {
match method {
crank_core::HttpMethod::Get => "get",
crank_core::HttpMethod::Post => "post",
crank_core::HttpMethod::Put => "put",
crank_core::HttpMethod::Patch => "patch",
crank_core::HttpMethod::Delete => "delete",
}
}
fn collect_operation_expectations(
operation: &NormalizedOperation,
version: &str,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let method = method_name(operation.method);
let pointer = format!("/paths/{}/{method}", escape_pointer(&operation.path));
let stable_id = format!("{version}:{method}:{pointer}");
if operation.location.pointer != pointer
|| operation.stable_id != stable_id
|| operation.key != format!("{} {}", method.to_ascii_uppercase(), operation.path)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
stable_id.clone(),
operation.location.clone(),
CoverageDisposition::Mapped,
)?;
let operation_id_location = SourceLocation {
pointer: format!("{pointer}/operationId"),
};
let operation_id_disposition = match operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
Some(value) if string_at(source_nodes, &operation_id_location.pointer) == Some(value) => {
CoverageDisposition::Mapped
}
Some(_) => return Err(ImportParseError::InvalidDocument),
None => CoverageDisposition::Finding,
};
if operation_id_disposition == CoverageDisposition::Mapped
|| source_nodes.contains_key(&operation_id_location.pointer)
{
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:operation_id"),
operation_id_location,
operation_id_disposition,
)?;
} else {
insert_expected(
expected,
format!("{stable_id}:operation_id"),
operation_id_location,
CoverageDisposition::Finding,
)?;
}
let tag_locations = normalized_tag_locations(source_nodes, &pointer, &operation.tags)?;
for (index, location) in tag_locations.into_iter().enumerate() {
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:tag:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
for parameter in &operation.parameters {
if parameter.construct_id
!= format!(
"parameter:{}",
escape_pointer(&parameter.source_location.pointer)
)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
parameter.construct_id.clone(),
parameter.source_location.clone(),
CoverageDisposition::Mapped,
)?;
if let Some(schema) = &parameter.schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
}
if let Some(schema) = &operation.request_body_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
if let Some(schema) = &operation.response_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
Ok(())
}
fn normalized_tag_locations(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let tags_pointer = format!("{operation_pointer}/tags");
let source_tags = nodes.get(&tags_pointer).and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(items),
_ => None,
});
let locations = source_tags
.into_iter()
.flatten()
.filter_map(|node| match &node.value {
CanonicalSourceValue::String(value) => Some((value, node.location.clone())),
_ => None,
})
.collect::<Vec<_>>();
if locations.iter().map(|(value, _)| *value).ne(tags.iter()) {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn collect_schema_expectations(
schema: &NormalizedSchema,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if schema.construct_id != format!("schema:{}", escape_pointer(&schema.location.pointer)) {
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
schema.construct_id.clone(),
schema.location.clone(),
CoverageDisposition::Mapped,
)?;
if schema.description
!= string_at(
source_nodes,
&format!("{}/description", schema.location.pointer),
)
.map(ToOwned::to_owned)
{
return Err(ImportParseError::InvalidDocument);
}
match &schema.kind {
NormalizedSchemaKind::Object { properties, .. } => {
for child in properties.values() {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
NormalizedSchemaKind::Array { items: Some(child) } => {
collect_schema_expectations(child, source_nodes, expected)?;
}
NormalizedSchemaKind::Composition { variants, .. } => {
for child in variants {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
_ => {}
}
Ok(())
}
fn collect_finding_only_expectations(
version: &str,
ir: &NormalizedIr,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if ir.source.format == "openapi" && ir.source.servers.len() > 1 {
let location = SourceLocation {
pointer: "/servers".to_owned(),
};
insert_real_expected(
expected,
source_nodes,
format!("{version}:/servers"),
location,
CoverageDisposition::Finding,
)?;
}
if ir.source.format == "openapi"
&& let Some(servers) = source_nodes.get("/servers").copied()
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Object(paths),
..
}) = source_nodes.get("/paths").copied()
else {
return Err(ImportParseError::InvalidDocument);
};
for path_item in paths.values() {
let CanonicalSourceValue::Object(methods) = &path_item.value else {
insert_finding_pointer(version, path_item, expected)?;
continue;
};
if ir.source.format == "openapi"
&& let Some(servers) = methods.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(parameters) = methods.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
for method in ["head", "options", "trace", "connect"] {
if let Some(node) = methods.get(method) {
insert_finding_pointer(version, node, expected)?;
}
}
for method in ["get", "post", "put", "patch", "delete"] {
if let Some(node) = methods.get(method) {
match &node.value {
CanonicalSourceValue::Object(operation) => {
if ir.source.format == "openapi"
&& let Some(servers) = operation.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(tags) = operation.get("tags") {
collect_invalid_tag_findings(version, tags, expected)?;
}
if let Some(parameters) = operation.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
}
_ => insert_finding_pointer(version, node, expected)?,
}
}
}
}
Ok(())
}
fn collect_invalid_openapi_server_findings(
version: &str,
servers: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &servers.value else {
return Ok(());
};
for server in items {
let valid = matches!(
&server.value,
CanonicalSourceValue::Object(fields)
if matches!(fields.get("url").map(|node| &node.value), Some(CanonicalSourceValue::String(_)))
);
if !valid {
insert_finding_pointer(version, server, expected)?;
}
}
Ok(())
}
fn collect_invalid_tag_findings(
version: &str,
tags: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &tags.value else {
return Ok(());
};
for tag in items {
if !matches!(tag.value, CanonicalSourceValue::String(_)) {
insert_finding_pointer(version, tag, expected)?;
}
}
Ok(())
}
fn collect_dropped_parameter_findings(
version: &str,
parameters: &CanonicalSourceNode,
format: &str,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &parameters.value else {
return Ok(());
};
for parameter in items {
let CanonicalSourceValue::Object(fields) = &parameter.value else {
insert_finding_pointer(version, parameter, expected)?;
continue;
};
if fields.contains_key("$ref") {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
if !matches!(
fields.get("name").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) || !matches!(
fields.get("in").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(location),
..
}) = fields.get("in")
else {
return Err(ImportParseError::InvalidDocument);
};
let supported = match format {
"openapi" => matches!(location.as_str(), "path" | "query" | "header"),
"swagger" => matches!(location.as_str(), "path" | "query" | "header" | "body"),
_ => false,
};
if !supported {
insert_finding_pointer(version, parameter, expected)?;
}
}
Ok(())
}
fn insert_finding_pointer(
version: &str,
node: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
insert_expected(
expected,
format!("{version}:{}", node.location.pointer),
node.location.clone(),
CoverageDisposition::Finding,
)
}
pub(super) fn references_from_source_tree(
source_tree: &CanonicalSourceNode,
) -> Vec<NormalizedReference> {
let mut references = Vec::new();
collect_references(source_tree, &mut references);
references.sort_by(|left, right| left.construct_id.cmp(&right.construct_id));
references
}
fn collect_references(node: &CanonicalSourceNode, references: &mut Vec<NormalizedReference>) {
match &node.value {
CanonicalSourceValue::Array(items) => {
for item in items {
collect_references(item, references);
}
}
CanonicalSourceValue::Object(items) => {
if let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(uri),
location,
..
}) = items.get("$ref")
{
references.push(NormalizedReference {
construct_id: format!("reference:{}", escape_pointer(&location.pointer)),
uri: uri.clone(),
location: location.clone(),
});
}
for item in items.values() {
collect_references(item, references);
}
}
_ => {}
}
}
pub(super) fn canonical_source_tree(
value: &Value,
pointer: &str,
coverage: &mut Vec<CoverageEntry>,
) -> CanonicalSourceNode {
let id = format!("source:{pointer}");
let location = SourceLocation {
pointer: pointer.to_owned(),
};
coverage.push(CoverageEntry {
construct_id: id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
let value = match value {
Value::Null => CanonicalSourceValue::Null,
Value::Bool(value) => CanonicalSourceValue::Boolean(*value),
Value::Number(value) => CanonicalSourceValue::Number(value.to_string()),
Value::String(value) => CanonicalSourceValue::String(value.clone()),
Value::Array(items) => CanonicalSourceValue::Array(
items
.iter()
.enumerate()
.map(|(index, value)| {
canonical_source_tree(value, &format!("{pointer}/{index}"), coverage)
})
.collect(),
),
Value::Object(items) => CanonicalSourceValue::Object(
items
.iter()
.map(|(key, value)| {
(
key.clone(),
canonical_source_tree(
value,
&format!("{pointer}/{}", escape_pointer(key)),
coverage,
),
)
})
.collect(),
),
};
CanonicalSourceNode {
construct_id: id,
location,
value,
}
}
pub(super) fn paths_from_operations(
operations: &[NormalizedOperation],
version: &str,
) -> Vec<NormalizedPath> {
let mut paths = BTreeMap::<String, Vec<String>>::new();
for operation in operations {
paths
.entry(operation.path.clone())
.or_default()
.push(operation.stable_id.clone());
}
paths
.into_iter()
.map(|(path, operation_ids)| NormalizedPath {
construct_id: format!("{version}:path:{path}"),
location: SourceLocation {
pointer: format!("/paths/{}", escape_pointer(&path)),
},
path,
operation_ids,
})
.collect()
}
pub(super) fn escape_pointer(value: &str) -> String {
value.replace('~', "~0").replace('/', "~1")
}