fix(apply): restore bare-bundle wire compat on the plan endpoints
§7 M3 wrapped the three plan request bodies in a `{bundle, project}` struct,
silently breaking version-skew compatibility: a pre-§7 `pic plan` POSTs a BARE
bundle at the JSON top level and now fails deserialization (422 "missing field
bundle"), even though the sibling apply request deliberately kept its added
fields additive (`#[serde(default)]`) so an older CLI still works.
`#[serde(flatten)]` the bundle in `PlanRequest`/`TreePlanRequest` so the bundle
fields sit at the top level again: a bare bundle deserializes (`project`
defaults to `None`) and a newer client sends the same fields plus a top-level
`project` key. The CLI now builds that shape via a `plan_body` helper (bundle
object + optional `project`), which an older server also accepts (Bundle has no
`deny_unknown_fields`, so the extra key is ignored) — compatible in both skew
directions. Apply is left unwrapped (it was already `{bundle, ...}` pre-§7).
Pinned by `plan_request_accepts_a_bare_bundle` / `_a_flattened_project` /
`tree_plan_request_accepts_a_bare_bundle` unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,12 @@ async fn group_extension_points_handler(
|
||||
/// `[project]`, so the plan can preview the ownership outcome + attach ceiling.
|
||||
#[derive(Deserialize)]
|
||||
pub struct PlanRequest {
|
||||
// Flattened so the bundle fields sit at the JSON top level: an older `pic`
|
||||
// (pre-§7) that POSTs a bare bundle still deserializes (`project` defaults
|
||||
// to `None`), and a newer client sends the same bundle fields plus a
|
||||
// top-level `project` key. Keeps the plan wire compatible in both skew
|
||||
// directions, matching the additive compatibility of the apply request.
|
||||
#[serde(flatten)]
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub project: Option<ProjectDecl>,
|
||||
@@ -327,6 +333,8 @@ struct TreeApplyRequest {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreePlanRequest {
|
||||
// Flattened for the same bare-bundle wire compatibility as `PlanRequest`.
|
||||
#[serde(flatten)]
|
||||
bundle: TreeBundle,
|
||||
#[serde(default)]
|
||||
project: Option<ProjectDecl>,
|
||||
@@ -577,3 +585,41 @@ impl IntoResponse for ApplyError {
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// §7 wire compat: the plan endpoints `#[serde(flatten)]` the bundle, so a
|
||||
// pre-§7 client that POSTs a BARE bundle (no `{bundle: ...}` wrapper, no
|
||||
// `project`) must still deserialize — `project` defaults to `None`.
|
||||
#[test]
|
||||
fn plan_request_accepts_a_bare_bundle() {
|
||||
let bare = serde_json::json!({
|
||||
"scripts": [],
|
||||
"secrets": ["TOKEN"],
|
||||
});
|
||||
let req: PlanRequest = serde_json::from_value(bare).expect("bare bundle deserializes");
|
||||
assert!(req.project.is_none());
|
||||
assert_eq!(req.bundle.secrets, vec!["TOKEN".to_string()]);
|
||||
}
|
||||
|
||||
// The newer wire shape — the same bundle fields at the top level plus a
|
||||
// `project` key (what the current CLI sends) — also deserializes.
|
||||
#[test]
|
||||
fn plan_request_accepts_a_flattened_project() {
|
||||
let with_proj = serde_json::json!({
|
||||
"scripts": [],
|
||||
"project": { "slug": "acme" },
|
||||
});
|
||||
let req: PlanRequest = serde_json::from_value(with_proj).expect("flattened project");
|
||||
assert_eq!(req.project.expect("project").slug, "acme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_plan_request_accepts_a_bare_bundle() {
|
||||
let bare = serde_json::json!({ "nodes": [] });
|
||||
let req: TreePlanRequest = serde_json::from_value(bare).expect("bare tree bundle");
|
||||
assert!(req.project.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user