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
|
/// 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user