fix(adapters): reject unbound placeholders when from_jsonschema input_schema has no properties

The eager placeholder-binding check in spec_name_references_undeclared
short-circuited on an empty properties set, letting
FromJsonSchema::new accept a template whose placeholder could never be
bound (every later call failed INTERNAL, or INVALID_INPUT if a peer
supplied the key). Drop the early return so any placeholder against a
declared-nothing (or properties-less) input_schema is unbound, matching
the from_openapi unbound_placeholders path.

- add construction-time test: no-properties schema + {id} template
  fails with the placeholder error; no-properties schema + placeholder-
  free template still constructs and imports as Visibility::Internal

Verification: scripts/verify.sh (342 passed), scripts/verify.sh
--all-features (454 passed); clippy -D warnings and fmt --check clean
This commit is contained in:
2026-08-30 21:06:08 +00:00
parent 5244dc46e2
commit 568954348b
+52 -3
View File
@@ -170,9 +170,6 @@ fn spec_name_references_undeclared(spec: &OperationSpec, path_template: &str) ->
.and_then(|p| p.as_object()) .and_then(|p| p.as_object())
.map(|p| p.keys().cloned().collect::<Vec<_>>()) .map(|p| p.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default(); .unwrap_or_default();
if properties.is_empty() {
return false;
}
let mut rest = path_template; let mut rest = path_template;
while let Some(start) = rest.find('{') { while let Some(start) = rest.find('{') {
let Some(end_rel) = rest[start..].find('}') else { let Some(end_rel) = rest[start..].find('}') else {
@@ -722,4 +719,56 @@ mod tests {
Err(e) => panic!("expected SchemaParse, got {e}"), Err(e) => panic!("expected SchemaParse, got {e}"),
} }
} }
#[test]
fn construction_rejects_placeholder_when_input_schema_has_no_properties() {
let spec = OperationSpec::new(
"svc/getWidget",
OperationType::Query,
Visibility::Internal,
serde_json::json!({"type":"object"}),
serde_json::json!({"type":"object"}),
vec![],
AccessControl::default(),
None,
);
let result = FromJsonSchema::new(
spec,
test_config("svc", "https://api.example.com"),
"/widgets/{id}".to_string(),
"GET".to_string(),
test_http_client(),
);
match result {
Err(AdapterError::SchemaParse { message }) => {
assert!(message.contains("placeholder"), "message was: {message}");
assert!(message.contains("{id}"), "message was: {message}");
}
Ok(_) => {
panic!("no-properties schema with a placeholder template must fail at construction")
}
Err(e) => panic!("expected SchemaParse, got {e}"),
}
let spec = OperationSpec::new(
"svc/listWidgets",
OperationType::Query,
Visibility::Internal,
serde_json::json!({"type":"object"}),
serde_json::json!({"type":"object"}),
vec![],
AccessControl::default(),
None,
);
let adapter = FromJsonSchema::new(
spec,
test_config("svc", "https://api.example.com"),
"/widgets".to_string(),
"GET".to_string(),
test_http_client(),
)
.expect("placeholder-free template with a no-properties schema builds");
let bundles = futures::executor::block_on(adapter.import()).unwrap();
assert_eq!(bundles[0].spec.visibility, Visibility::Internal);
}
} }