1use serde_json::{Value, json};
6
7use crate::observe::HttpMethod;
8use crate::path::JsonPath;
9use crate::verdict::{Gvr, Verb};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum OpKind {
15 Object,
17 List,
19 Discovery,
21}
22
23#[derive(Clone, Debug, Default)]
26pub struct RestPath(Vec<String>);
27
28impl RestPath {
29 #[must_use]
30 pub fn new() -> Self {
31 Self::default()
32 }
33
34 #[must_use]
35 pub fn seg(mut self, s: impl Into<String>) -> Self {
36 self.0.push(s.into());
37 self
38 }
39
40 #[must_use]
42 pub fn render(&self) -> String {
43 let mut out = String::new();
44 for s in &self.0 {
45 out.push('/');
46 out.push_str(s);
47 }
48 out
49 }
50}
51
52#[derive(Clone, Debug)]
54pub struct Operation {
55 pub id: String,
57 pub method: HttpMethod,
59 pub path: String,
61 pub body: Option<Value>,
63 pub content_type: Option<String>,
65 pub verb_probe: Option<(Gvr, Verb)>,
70 pub kind: OpKind,
72 pub focus: Option<JsonPath>,
79}
80
81fn configmaps_gvr() -> Gvr {
82 Gvr {
83 group: String::new(),
84 version: "v1".into(),
85 resource: "configmaps".into(),
86 }
87}
88
89impl Operation {
90 fn object(id: String, method: HttpMethod, path: String) -> Self {
97 Self {
98 id,
99 method,
100 path,
101 body: None,
102 content_type: None,
103 verb_probe: None,
104 kind: OpKind::Object,
105 focus: None,
106 }
107 }
108
109 #[must_use]
110 fn with_body(mut self, body: Value, content_type: &str) -> Self {
111 self.body = Some(body);
112 self.content_type = Some(content_type.into());
113 self
114 }
115
116 #[must_use]
117 fn with_kind(mut self, kind: OpKind) -> Self {
118 self.kind = kind;
119 self
120 }
121
122 #[must_use]
123 fn with_verb_probe(mut self, gvr: Gvr, verb: Verb) -> Self {
124 self.verb_probe = Some((gvr, verb));
125 self
126 }
127
128 #[must_use]
131 pub fn with_focus(mut self, focus: JsonPath) -> Self {
132 self.focus = Some(focus);
133 self
134 }
135
136 fn ns_collection(ns: &str, plural: &str) -> RestPath {
138 RestPath::new()
139 .seg("api")
140 .seg("v1")
141 .seg("namespaces")
142 .seg(ns)
143 .seg(plural)
144 }
145
146 fn ns_configmaps(ns: &str) -> RestPath {
147 Self::ns_collection(ns, "configmaps")
148 }
149
150 fn ident(prefix: &str, name: &str) -> String {
151 let mut id = String::from(prefix);
152 id.push_str(name);
153 id
154 }
155
156 #[must_use]
158 pub fn create_namespace(name: &str) -> Self {
159 Self::object(
160 Self::ident("create_namespace/", name),
161 HttpMethod::Post,
162 RestPath::new()
163 .seg("api")
164 .seg("v1")
165 .seg("namespaces")
166 .render(),
167 )
168 .with_body(
169 json!({
170 "apiVersion": "v1",
171 "kind": "Namespace",
172 "metadata": {"name": name},
173 }),
174 "application/json",
175 )
176 }
177
178 #[must_use]
180 pub fn get_namespace(name: &str) -> Self {
181 Self::object(
182 Self::ident("get_namespace/", name),
183 HttpMethod::Get,
184 RestPath::new()
185 .seg("api")
186 .seg("v1")
187 .seg("namespaces")
188 .seg(name)
189 .render(),
190 )
191 }
192
193 #[must_use]
195 pub fn create_configmap(ns: &str, name: &str, data: Value) -> Self {
196 Self::object(
197 Self::ident("create_configmap/", name),
198 HttpMethod::Post,
199 Self::ns_configmaps(ns).render(),
200 )
201 .with_body(
202 json!({
203 "apiVersion": "v1",
204 "kind": "ConfigMap",
205 "metadata": {"name": name},
206 "data": data,
207 }),
208 "application/json",
209 )
210 }
211
212 #[must_use]
214 pub fn get_configmap(ns: &str, name: &str) -> Self {
215 Self::object(
216 Self::ident("get_configmap/", name),
217 HttpMethod::Get,
218 Self::ns_configmaps(ns).seg(name).render(),
219 )
220 }
221
222 #[must_use]
226 pub fn create_labeled_configmap(ns: &str, name: &str, labels: &[(&str, &str)]) -> Self {
227 let labels: serde_json::Map<String, Value> = labels
228 .iter()
229 .map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
230 .collect();
231 Self::object(
232 Self::ident("create_configmap/", name),
233 HttpMethod::Post,
234 Self::ns_configmaps(ns).render(),
235 )
236 .with_body(
237 json!({
238 "apiVersion": "v1",
239 "kind": "ConfigMap",
240 "metadata": {"name": name, "labels": Value::Object(labels)},
241 "data": {"a": "b"},
242 }),
243 "application/json",
244 )
245 }
246
247 #[must_use]
251 pub fn list_configmaps(ns: &str, name_filter: &str) -> Self {
252 let mut path = Self::ns_configmaps(ns).render();
253 path.push_str("?fieldSelector=metadata.name%3D");
254 path.push_str(name_filter);
255 Self::object(
256 Self::ident("list_configmaps/", name_filter),
257 HttpMethod::Get,
258 path,
259 )
260 .with_kind(OpKind::List)
261 }
262
263 #[must_use]
265 pub fn merge_patch_configmap(ns: &str, name: &str, patch: Value) -> Self {
266 Self::object(
267 Self::ident("merge_patch_configmap/", name),
268 HttpMethod::Patch,
269 Self::ns_configmaps(ns).seg(name).render(),
270 )
271 .with_body(patch, "application/merge-patch+json")
272 }
273
274 #[must_use]
278 pub fn replace_configmap(ns: &str, name: &str, body: Value) -> Self {
279 Self::object(
280 Self::ident("replace_configmap/", name),
281 HttpMethod::Put,
282 Self::ns_configmaps(ns).seg(name).render(),
283 )
284 .with_body(body, "application/json")
285 .with_verb_probe(configmaps_gvr(), Verb::Update)
286 }
287
288 #[must_use]
290 pub fn delete_configmap(ns: &str, name: &str) -> Self {
291 Self::object(
292 Self::ident("delete_configmap/", name),
293 HttpMethod::Delete,
294 Self::ns_configmaps(ns).seg(name).render(),
295 )
296 }
297
298 #[must_use]
300 pub fn delete_namespace(name: &str) -> Self {
301 Self::object(
302 Self::ident("delete_namespace/", name),
303 HttpMethod::Delete,
304 RestPath::new()
305 .seg("api")
306 .seg("v1")
307 .seg("namespaces")
308 .seg(name)
309 .render(),
310 )
311 }
312
313 #[must_use]
315 pub fn discovery_core_v1() -> Self {
316 Self::object(
317 "discovery/api/v1".into(),
318 HttpMethod::Get,
319 RestPath::new().seg("api").seg("v1").render(),
320 )
321 .with_kind(OpKind::Discovery)
322 }
323
324 #[must_use]
328 pub fn merge_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
329 Self::object(
330 Self::ident("merge_patch/", name),
331 HttpMethod::Patch,
332 Self::ns_collection(ns, plural).seg(name).render(),
333 )
334 .with_body(patch, "application/merge-patch+json")
335 }
336
337 #[must_use]
341 pub fn strategic_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
342 Self::object(
343 Self::ident("strategic_patch/", name),
344 HttpMethod::Patch,
345 Self::ns_collection(ns, plural).seg(name).render(),
346 )
347 .with_body(patch, "application/strategic-merge-patch+json")
348 }
349
350 #[must_use]
353 pub fn json_patch(ns: &str, plural: &str, name: &str, ops: Value) -> Self {
354 Self::object(
355 Self::ident("json_patch/", name),
356 HttpMethod::Patch,
357 Self::ns_collection(ns, plural).seg(name).render(),
358 )
359 .with_body(ops, "application/json-patch+json")
360 }
361
362 #[must_use]
364 pub fn get(ns: &str, plural: &str, name: &str) -> Self {
365 Self::object(
366 Self::ident("get/", name),
367 HttpMethod::Get,
368 Self::ns_collection(ns, plural).seg(name).render(),
369 )
370 }
371
372 #[must_use]
376 pub fn create_pod(ns: &str, name: &str, image: &str) -> Self {
377 Self::create_pod_containers(ns, name, &[("c", image)])
378 }
379
380 #[must_use]
384 pub fn create_pod_containers(ns: &str, name: &str, containers: &[(&str, &str)]) -> Self {
385 let containers: Vec<Value> = containers
386 .iter()
387 .map(|(n, img)| json!({"name": n, "image": img}))
388 .collect();
389 Self::object(
390 Self::ident("create_pod/", name),
391 HttpMethod::Post,
392 Self::ns_collection(ns, "pods").render(),
393 )
394 .with_body(
395 json!({
396 "apiVersion": "v1",
397 "kind": "Pod",
398 "metadata": {"name": name},
399 "spec": {"containers": containers},
400 }),
401 "application/json",
402 )
403 }
404
405 #[must_use]
407 pub fn delete_pod(ns: &str, name: &str) -> Self {
408 let mut path = Self::ns_collection(ns, "pods").seg(name).render();
409 path.push_str("?gracePeriodSeconds=0");
410 Self::object(Self::ident("delete_pod/", name), HttpMethod::Delete, path)
411 }
412
413 #[must_use]
417 pub fn get_status(ns: &str, plural: &str, name: &str) -> Self {
418 Self::object(
419 Self::ident("get_status/", name),
420 HttpMethod::Get,
421 Self::ns_collection(ns, plural)
422 .seg(name)
423 .seg("status")
424 .render(),
425 )
426 }
427
428 #[must_use]
430 pub fn put_status(ns: &str, plural: &str, name: &str, body: Value) -> Self {
431 Self::object(
432 Self::ident("put_status/", name),
433 HttpMethod::Put,
434 Self::ns_collection(ns, plural)
435 .seg(name)
436 .seg("status")
437 .render(),
438 )
439 .with_body(body, "application/json")
440 }
441
442 #[must_use]
444 pub fn replace(ns: &str, plural: &str, name: &str, body: Value) -> Self {
445 Self::object(
446 Self::ident("replace/", name),
447 HttpMethod::Put,
448 Self::ns_collection(ns, plural).seg(name).render(),
449 )
450 .with_body(body, "application/json")
451 }
452
453 #[must_use]
461 pub fn list_selector(
462 ns: &str,
463 plural: &str,
464 id_tag: &str,
465 label_selector: Option<&str>,
466 field_selector: Option<&str>,
467 ) -> Self {
468 let mut path = Self::ns_collection(ns, plural).render();
469 let mut sep = '?';
470 if let Some(ls) = label_selector {
471 path.push(sep);
472 path.push_str("labelSelector=");
473 path.push_str(&pct_encode(ls));
474 sep = '&';
475 }
476 if let Some(fs) = field_selector {
477 path.push(sep);
478 path.push_str("fieldSelector=");
479 path.push_str(&pct_encode(fs));
480 }
481 Self::object(Self::ident("list_selector/", id_tag), HttpMethod::Get, path)
482 .with_kind(OpKind::List)
483 }
484
485 #[must_use]
487 pub fn body_bytes(&self) -> Option<Vec<u8>> {
488 self.body
489 .as_ref()
490 .map(|b| serde_json::to_vec(b).unwrap_or_default())
491 }
492}
493
494#[must_use]
498pub fn pct_encode(s: &str) -> String {
499 const HEX: &[u8; 16] = b"0123456789ABCDEF";
500 let mut out = String::with_capacity(s.len());
501 for &b in s.as_bytes() {
502 match b {
503 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
504 out.push(b as char);
505 }
506 _ => {
507 out.push('%');
508 out.push(HEX[(b >> 4) as usize] as char);
509 out.push(HEX[(b & 0x0f) as usize] as char);
510 }
511 }
512 }
513 out
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn paths_render_typed() {
522 assert_eq!(Operation::create_namespace("x").path, "/api/v1/namespaces");
523 assert_eq!(
524 Operation::get_configmap("ns", "cm").path,
525 "/api/v1/namespaces/ns/configmaps/cm"
526 );
527 assert_eq!(
528 Operation::list_configmaps("ns", "cm").path,
529 "/api/v1/namespaces/ns/configmaps?fieldSelector=metadata.name%3Dcm"
530 );
531 }
532
533 #[test]
534 fn replace_carries_verb_probe() {
535 let op = Operation::replace_configmap("ns", "cm", json!({}));
536 let (gvr, verb) = op.verb_probe.expect("replace is a verb probe");
537 assert_eq!(gvr.resource, "configmaps");
538 assert_eq!(verb, Verb::Update);
539 }
540
541 #[test]
542 fn patch_variants_carry_the_content_type() {
543 assert_eq!(
544 Operation::merge_patch("ns", "configmaps", "cm", json!({})).content_type,
545 Some("application/merge-patch+json".into())
546 );
547 assert_eq!(
548 Operation::strategic_patch("ns", "pods", "p", json!({})).content_type,
549 Some("application/strategic-merge-patch+json".into())
550 );
551 assert_eq!(
552 Operation::json_patch("ns", "pods", "p", json!([])).content_type,
553 Some("application/json-patch+json".into())
554 );
555 }
556
557 #[test]
558 fn status_paths_render() {
559 assert_eq!(
560 Operation::get_status("ns", "pods", "p").path,
561 "/api/v1/namespaces/ns/pods/p/status"
562 );
563 assert_eq!(
564 Operation::put_status("ns", "pods", "p", json!({})).path,
565 "/api/v1/namespaces/ns/pods/p/status"
566 );
567 }
568
569 #[test]
570 fn selector_query_is_percent_encoded() {
571 let op =
572 Operation::list_selector("ns", "configmaps", "in", Some("tier in (web,api)"), None);
573 assert_eq!(
574 op.path,
575 "/api/v1/namespaces/ns/configmaps?labelSelector=tier%20in%20%28web%2Capi%29"
576 );
577 assert_eq!(op.kind, OpKind::List);
578 let both = Operation::list_selector(
579 "ns",
580 "configmaps",
581 "ne",
582 Some("a!=b"),
583 Some("metadata.name=x"),
584 );
585 assert_eq!(
586 both.path,
587 "/api/v1/namespaces/ns/configmaps?labelSelector=a%21%3Db&fieldSelector=metadata.name%3Dx"
588 );
589 }
590
591 #[test]
592 fn focus_is_carried() {
593 let op = Operation::get("ns", "pods", "p")
594 .with_focus(JsonPath::parse("spec.containers[0].image"));
595 assert_eq!(
596 op.focus.map(|p| p.to_string()),
597 Some(".spec.containers[0].image".to_string())
598 );
599 }
600}