feat(adapters): structural path-placeholder values are an INVALID_INPUT error (FWD-18)

The routing half of FWD-18 was already in place (placeholder keys skip
query routing before value-shape inspection); this pins it with a test
and decides the structural-value outcome: an object/array value under a
placeholder key now fails with INVALID_INPUT instead of splicing the
minified JSON into the path segment. Scalars (string/number/bool/null)
render as before.
This commit is contained in:
2026-08-30 20:54:05 +00:00
parent 3d932be75f
commit 857aedb987
+103 -6
View File
@@ -420,11 +420,26 @@ fn scalar_value_to_string(value: &Value) -> String {
/// Percent-encoding scheme used by [`render_path_template`]: a value /// Percent-encoding scheme used by [`render_path_template`]: a value
/// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is /// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is
/// encoded so that a rendered value stays one literal path segment /// encoded so that a rendered value stays one literal path segment
/// (FWD-01 traversal/ smuggled-segments gate). Path parameters are never /// (FWD-01 traversal/ smuggled-segments gate). Scalars are never
/// rejected: they are rendered safely instead. /// rejected: they are rendered safely instead. Object/array values
pub(crate) fn value_to_path_segment(value: &Value) -> String { /// under a placeholder key are rejected with `INVALID_INPUT` (FWD-18):
let raw = scalar_value_to_string(value); /// a path placeholder designates exactly one literal segment, so a
utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string() /// structural value has no faithful rendering — the pre-decision
/// behavior spliced the minified JSON into the path *and* emitted the
/// key as a query parameter (double-routed, neither faithful).
pub(crate) fn value_to_path_segment(value: &Value) -> Result<String, CallError> {
let raw = match value {
Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => {
scalar_value_to_string(value)
}
other => {
return Err(CallError::invalid_input(format!(
"path placeholder value must be a scalar (string, number, boolean, or null); got {}: structural values have no faithful single-segment rendering",
type_name_of(other)
)))
}
};
Ok(utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string())
} }
/// Raw form of a scalar input value for query emission: `&`/`=` /// Raw form of a scalar input value for query emission: `&`/`=`
@@ -469,7 +484,7 @@ pub(crate) fn render_path_template(
continue; continue;
} }
}; };
out.push_str(&value_to_path_segment(raw_value)); out.push_str(&value_to_path_segment(raw_value)?);
rest = &tail[end + 1..]; rest = &tail[end + 1..];
} }
out.push_str(rest); out.push_str(rest);
@@ -1511,6 +1526,88 @@ mod tests {
assert!(q.contains("q=rust") && q.contains("debug=true"), "{q}"); assert!(q.contains("q=rust") && q.contains("debug=true"), "{q}");
} }
/// FWD-18, routing half pin: a key that matched a placeholder never
/// also emits as a query parameter — the skip in `build_request`
/// precedes query routing and is value-shape-agnostic. The scalar
/// case renders into the path only; the object-value case is
/// rejected (see the structural-value test below), so here the
/// companion non-placeholder key proves query routing stays intact
/// around the placeholder skip.
#[test]
fn placeholder_keys_never_double_route_as_query_params() {
let url = request_url(
"https://api.example.com",
"/repos/{owner}",
json!({"owner": "octocat", "extra": "q-val"}),
)
.expect("scalar placeholder builds");
assert_eq!(url.path(), "/repos/octocat");
assert_eq!(
url.query(),
Some("extra=q-val"),
"only the non-placeholder key routes to query"
);
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"id": {"type": "string"}},
});
let err = build_request(
"https://api.example.com",
"/items/{id}",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
&json!({"id": "x", "undeclared": "v"}),
&ctx,
)
.expect_err("undeclared key still rejected with a placeholder present");
assert_eq!(err.code, "INVALID_INPUT");
}
/// FWD-18, structural-value decision: an object/array value under a
/// placeholder key is an `INVALID_INPUT` error, not a spliced JSON
/// segment. A path placeholder designates exactly one literal
/// segment; the pre-decision behavior embedded the minified JSON
/// into the path (and, under an additionalProperties schema, also
/// emitted the key as a query param — the double-route).
#[test]
fn object_or_array_path_values_error_instead_of_splicing_json_into_the_path() {
for value in [json!({"a": 1}), json!([1, 2])] {
let err = request_url(
"https://api.example.com",
"/things/{id}",
json!({"id": value}),
)
.expect_err("structural path values must be rejected");
assert_eq!(err.code, "INVALID_INPUT", "value was: {value}");
assert!(
err.message.contains("must be a scalar"),
"value was: {value}"
);
assert!(
!err.message.contains("{\"a\":1}") && !err.message.contains("[1,2]"),
"error must not echo the structural value: {value}"
);
}
for value in [json!("text"), json!(42), json!(true)] {
let url = request_url(
"https://api.example.com",
"/things/{id}",
json!({ "id": value }),
)
.expect("scalar path values still render");
assert!(
url.path().starts_with("/things/"),
"scalar {value} renders into the path"
);
}
}
fn ctx_with_capability(namespace: &str, value: String) -> OperationContext { fn ctx_with_capability(namespace: &str, value: String) -> OperationContext {
let mut ctx = noop_context(); let mut ctx = noop_context();
ctx.capabilities = Capabilities::new().with_http_token(namespace, value); ctx.capabilities = Capabilities::new().with_http_token(namespace, value);