Skip to main content

engenho_types/
patch.rs

1//! Typed patch operations. Maps to the apiserver's PATCH endpoint with
2//! the correct `Content-Type` per variant.
3
4use serde::{Deserialize, Serialize};
5
6/// A patch operation against an existing resource.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub enum Patch {
9    /// JSON Merge Patch (RFC 7396). Content-Type:
10    /// `application/merge-patch+json`. The simplest variant — replace
11    /// fields named in the patch, leave others alone.
12    Merge(serde_json::Value),
13
14    /// Strategic Merge Patch — Kubernetes-specific, knows how to merge
15    /// arrays of objects (e.g. `containers`) by key. Content-Type:
16    /// `application/strategic-merge-patch+json`. Default for `kubectl
17    /// patch` without `--type`.
18    Strategic(serde_json::Value),
19
20    /// JSON Patch (RFC 6902) — list of add/remove/replace ops.
21    /// Content-Type: `application/json-patch+json`.
22    Json(Vec<JsonPatchOp>),
23
24    /// Server-Side Apply — declarative reconciliation with apiserver-
25    /// side field-ownership tracking. Content-Type:
26    /// `application/apply-patch+yaml`. Carries the field-manager id
27    /// the caller speaks for.
28    Apply {
29        /// Identity of the caller doing the apply (e.g. `engenho-scheduler`).
30        field_manager: String,
31        /// Whether to take ownership of conflicting fields (rare).
32        force:         bool,
33        /// The (possibly partial) resource state we're declaring.
34        body:          serde_json::Value,
35    },
36}
37
38/// One operation in an RFC 6902 JSON Patch document.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase", tag = "op")]
41pub enum JsonPatchOp {
42    /// `{"op": "add", "path": "/spec/replicas", "value": 3}`
43    Add { path: String, value: serde_json::Value },
44    /// `{"op": "remove", "path": "/spec/replicas"}`
45    Remove { path: String },
46    /// `{"op": "replace", "path": "/spec/replicas", "value": 5}`
47    Replace { path: String, value: serde_json::Value },
48    /// `{"op": "copy", "from": "/a", "path": "/b"}`
49    Copy { from: String, path: String },
50    /// `{"op": "move", "from": "/a", "path": "/b"}`
51    Move { from: String, path: String },
52    /// `{"op": "test", "path": "/a", "value": 1}`
53    Test { path: String, value: serde_json::Value },
54}
55
56impl Patch {
57    /// The HTTP `Content-Type` header value the apiserver expects for
58    /// this patch variant.
59    #[must_use]
60    pub fn content_type(&self) -> &'static str {
61        match self {
62            Self::Merge(_)     => "application/merge-patch+json",
63            Self::Strategic(_) => "application/strategic-merge-patch+json",
64            Self::Json(_)      => "application/json-patch+json",
65            Self::Apply { .. } => "application/apply-patch+yaml",
66        }
67    }
68
69    /// Serialize the patch body to a `Vec<u8>` ready for the request.
70    ///
71    /// # Errors
72    ///
73    /// Returns the underlying serde_json error if serialization fails
74    /// (effectively unreachable — all our variants are owned-data).
75    pub fn body_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
76        match self {
77            Self::Merge(v)     | Self::Strategic(v) => serde_json::to_vec(v),
78            Self::Json(ops)                         => serde_json::to_vec(ops),
79            Self::Apply { body, .. }                => serde_json::to_vec(body),
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use serde_json::json;
88
89    #[test]
90    fn merge_content_type() {
91        let p = Patch::Merge(json!({"spec": {"replicas": 3}}));
92        assert_eq!(p.content_type(), "application/merge-patch+json");
93    }
94
95    #[test]
96    fn strategic_content_type() {
97        let p = Patch::Strategic(json!({}));
98        assert_eq!(p.content_type(), "application/strategic-merge-patch+json");
99    }
100
101    #[test]
102    fn json_patch_content_type() {
103        let p = Patch::Json(vec![JsonPatchOp::Add {
104            path:  "/spec/replicas".into(),
105            value: json!(3),
106        }]);
107        assert_eq!(p.content_type(), "application/json-patch+json");
108    }
109
110    #[test]
111    fn apply_content_type_and_field_manager() {
112        let p = Patch::Apply {
113            field_manager: "engenho-scheduler".into(),
114            force:         false,
115            body:          json!({"apiVersion": "v1", "kind": "Pod"}),
116        };
117        assert_eq!(p.content_type(), "application/apply-patch+yaml");
118    }
119
120    #[test]
121    fn json_patch_body_roundtrips() {
122        let p = Patch::Json(vec![
123            JsonPatchOp::Add { path: "/a".into(), value: json!(1) },
124            JsonPatchOp::Remove { path: "/b".into() },
125            JsonPatchOp::Replace { path: "/c".into(), value: json!("x") },
126            JsonPatchOp::Copy { from: "/d".into(), path: "/e".into() },
127            JsonPatchOp::Move { from: "/f".into(), path: "/g".into() },
128            JsonPatchOp::Test { path: "/h".into(), value: json!(true) },
129        ]);
130        let bytes = p.body_bytes().unwrap();
131        // Round-trip
132        let back: Vec<JsonPatchOp> = serde_json::from_slice(&bytes).unwrap();
133        assert_eq!(back.len(), 6);
134    }
135}