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:
+103
-6
@@ -420,11 +420,26 @@ fn scalar_value_to_string(value: &Value) -> String {
|
||||
/// Percent-encoding scheme used by [`render_path_template`]: a value
|
||||
/// containing `/`, `?`, `#`, or `\` — and any raw `%` it carries — is
|
||||
/// encoded so that a rendered value stays one literal path segment
|
||||
/// (FWD-01 traversal/ smuggled-segments gate). Path parameters are never
|
||||
/// rejected: they are rendered safely instead.
|
||||
pub(crate) fn value_to_path_segment(value: &Value) -> String {
|
||||
let raw = scalar_value_to_string(value);
|
||||
utf8_percent_encode(&raw, PATH_VALUE_ENCODE_SET).to_string()
|
||||
/// (FWD-01 traversal/ smuggled-segments gate). Scalars are never
|
||||
/// rejected: they are rendered safely instead. Object/array values
|
||||
/// under a placeholder key are rejected with `INVALID_INPUT` (FWD-18):
|
||||
/// a path placeholder designates exactly one literal segment, so a
|
||||
/// 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: `&`/`=`
|
||||
@@ -469,7 +484,7 @@ pub(crate) fn render_path_template(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push_str(&value_to_path_segment(raw_value));
|
||||
out.push_str(&value_to_path_segment(raw_value)?);
|
||||
rest = &tail[end + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
@@ -1511,6 +1526,88 @@ mod tests {
|
||||
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 {
|
||||
let mut ctx = noop_context();
|
||||
ctx.capabilities = Capabilities::new().with_http_token(namespace, value);
|
||||
|
||||
Reference in New Issue
Block a user