use serde_json::{Value, json};
use crate::observe::HttpMethod;
use crate::path::JsonPath;
use crate::verdict::{Gvr, Verb};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OpKind {
Object,
List,
Discovery,
}
#[derive(Clone, Debug, Default)]
pub struct RestPath(Vec<String>);
impl RestPath {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn seg(mut self, s: impl Into<String>) -> Self {
self.0.push(s.into());
self
}
#[must_use]
pub fn render(&self) -> String {
let mut out = String::new();
for s in &self.0 {
out.push('/');
out.push_str(s);
}
out
}
}
#[derive(Clone, Debug)]
pub struct Operation {
pub id: String,
pub method: HttpMethod,
pub path: String,
pub body: Option<Value>,
pub content_type: Option<String>,
pub verb_probe: Option<(Gvr, Verb)>,
pub kind: OpKind,
pub focus: Option<JsonPath>,
}
fn configmaps_gvr() -> Gvr {
Gvr {
group: String::new(),
version: "v1".into(),
resource: "configmaps".into(),
}
}
impl Operation {
fn object(id: String, method: HttpMethod, path: String) -> Self {
Self {
id,
method,
path,
body: None,
content_type: None,
verb_probe: None,
kind: OpKind::Object,
focus: None,
}
}
#[must_use]
fn with_body(mut self, body: Value, content_type: &str) -> Self {
self.body = Some(body);
self.content_type = Some(content_type.into());
self
}
#[must_use]
fn with_kind(mut self, kind: OpKind) -> Self {
self.kind = kind;
self
}
#[must_use]
fn with_verb_probe(mut self, gvr: Gvr, verb: Verb) -> Self {
self.verb_probe = Some((gvr, verb));
self
}
#[must_use]
pub fn with_focus(mut self, focus: JsonPath) -> Self {
self.focus = Some(focus);
self
}
fn ns_collection(ns: &str, plural: &str) -> RestPath {
RestPath::new()
.seg("api")
.seg("v1")
.seg("namespaces")
.seg(ns)
.seg(plural)
}
fn ns_configmaps(ns: &str) -> RestPath {
Self::ns_collection(ns, "configmaps")
}
fn ident(prefix: &str, name: &str) -> String {
let mut id = String::from(prefix);
id.push_str(name);
id
}
#[must_use]
pub fn create_namespace(name: &str) -> Self {
Self::object(
Self::ident("create_namespace/", name),
HttpMethod::Post,
RestPath::new()
.seg("api")
.seg("v1")
.seg("namespaces")
.render(),
)
.with_body(
json!({
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {"name": name},
}),
"application/json",
)
}
#[must_use]
pub fn get_namespace(name: &str) -> Self {
Self::object(
Self::ident("get_namespace/", name),
HttpMethod::Get,
RestPath::new()
.seg("api")
.seg("v1")
.seg("namespaces")
.seg(name)
.render(),
)
}
#[must_use]
pub fn create_configmap(ns: &str, name: &str, data: Value) -> Self {
Self::object(
Self::ident("create_configmap/", name),
HttpMethod::Post,
Self::ns_configmaps(ns).render(),
)
.with_body(
json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": name},
"data": data,
}),
"application/json",
)
}
#[must_use]
pub fn get_configmap(ns: &str, name: &str) -> Self {
Self::object(
Self::ident("get_configmap/", name),
HttpMethod::Get,
Self::ns_configmaps(ns).seg(name).render(),
)
}
#[must_use]
pub fn create_labeled_configmap(ns: &str, name: &str, labels: &[(&str, &str)]) -> Self {
let labels: serde_json::Map<String, Value> = labels
.iter()
.map(|(k, v)| ((*k).to_string(), Value::String((*v).to_string())))
.collect();
Self::object(
Self::ident("create_configmap/", name),
HttpMethod::Post,
Self::ns_configmaps(ns).render(),
)
.with_body(
json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {"name": name, "labels": Value::Object(labels)},
"data": {"a": "b"},
}),
"application/json",
)
}
#[must_use]
pub fn list_configmaps(ns: &str, name_filter: &str) -> Self {
let mut path = Self::ns_configmaps(ns).render();
path.push_str("?fieldSelector=metadata.name%3D");
path.push_str(name_filter);
Self::object(
Self::ident("list_configmaps/", name_filter),
HttpMethod::Get,
path,
)
.with_kind(OpKind::List)
}
#[must_use]
pub fn merge_patch_configmap(ns: &str, name: &str, patch: Value) -> Self {
Self::object(
Self::ident("merge_patch_configmap/", name),
HttpMethod::Patch,
Self::ns_configmaps(ns).seg(name).render(),
)
.with_body(patch, "application/merge-patch+json")
}
#[must_use]
pub fn replace_configmap(ns: &str, name: &str, body: Value) -> Self {
Self::object(
Self::ident("replace_configmap/", name),
HttpMethod::Put,
Self::ns_configmaps(ns).seg(name).render(),
)
.with_body(body, "application/json")
.with_verb_probe(configmaps_gvr(), Verb::Update)
}
#[must_use]
pub fn delete_configmap(ns: &str, name: &str) -> Self {
Self::object(
Self::ident("delete_configmap/", name),
HttpMethod::Delete,
Self::ns_configmaps(ns).seg(name).render(),
)
}
#[must_use]
pub fn delete_namespace(name: &str) -> Self {
Self::object(
Self::ident("delete_namespace/", name),
HttpMethod::Delete,
RestPath::new()
.seg("api")
.seg("v1")
.seg("namespaces")
.seg(name)
.render(),
)
}
#[must_use]
pub fn discovery_core_v1() -> Self {
Self::object(
"discovery/api/v1".into(),
HttpMethod::Get,
RestPath::new().seg("api").seg("v1").render(),
)
.with_kind(OpKind::Discovery)
}
#[must_use]
pub fn merge_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
Self::object(
Self::ident("merge_patch/", name),
HttpMethod::Patch,
Self::ns_collection(ns, plural).seg(name).render(),
)
.with_body(patch, "application/merge-patch+json")
}
#[must_use]
pub fn strategic_patch(ns: &str, plural: &str, name: &str, patch: Value) -> Self {
Self::object(
Self::ident("strategic_patch/", name),
HttpMethod::Patch,
Self::ns_collection(ns, plural).seg(name).render(),
)
.with_body(patch, "application/strategic-merge-patch+json")
}
#[must_use]
pub fn json_patch(ns: &str, plural: &str, name: &str, ops: Value) -> Self {
Self::object(
Self::ident("json_patch/", name),
HttpMethod::Patch,
Self::ns_collection(ns, plural).seg(name).render(),
)
.with_body(ops, "application/json-patch+json")
}
#[must_use]
pub fn get(ns: &str, plural: &str, name: &str) -> Self {
Self::object(
Self::ident("get/", name),
HttpMethod::Get,
Self::ns_collection(ns, plural).seg(name).render(),
)
}
#[must_use]
pub fn create_pod(ns: &str, name: &str, image: &str) -> Self {
Self::create_pod_containers(ns, name, &[("c", image)])
}
#[must_use]
pub fn create_pod_containers(ns: &str, name: &str, containers: &[(&str, &str)]) -> Self {
let containers: Vec<Value> = containers
.iter()
.map(|(n, img)| json!({"name": n, "image": img}))
.collect();
Self::object(
Self::ident("create_pod/", name),
HttpMethod::Post,
Self::ns_collection(ns, "pods").render(),
)
.with_body(
json!({
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": name},
"spec": {"containers": containers},
}),
"application/json",
)
}
#[must_use]
pub fn delete_pod(ns: &str, name: &str) -> Self {
let mut path = Self::ns_collection(ns, "pods").seg(name).render();
path.push_str("?gracePeriodSeconds=0");
Self::object(Self::ident("delete_pod/", name), HttpMethod::Delete, path)
}
#[must_use]
pub fn get_status(ns: &str, plural: &str, name: &str) -> Self {
Self::object(
Self::ident("get_status/", name),
HttpMethod::Get,
Self::ns_collection(ns, plural)
.seg(name)
.seg("status")
.render(),
)
}
#[must_use]
pub fn put_status(ns: &str, plural: &str, name: &str, body: Value) -> Self {
Self::object(
Self::ident("put_status/", name),
HttpMethod::Put,
Self::ns_collection(ns, plural)
.seg(name)
.seg("status")
.render(),
)
.with_body(body, "application/json")
}
#[must_use]
pub fn replace(ns: &str, plural: &str, name: &str, body: Value) -> Self {
Self::object(
Self::ident("replace/", name),
HttpMethod::Put,
Self::ns_collection(ns, plural).seg(name).render(),
)
.with_body(body, "application/json")
}
#[must_use]
pub fn list_selector(
ns: &str,
plural: &str,
id_tag: &str,
label_selector: Option<&str>,
field_selector: Option<&str>,
) -> Self {
let mut path = Self::ns_collection(ns, plural).render();
let mut sep = '?';
if let Some(ls) = label_selector {
path.push(sep);
path.push_str("labelSelector=");
path.push_str(&pct_encode(ls));
sep = '&';
}
if let Some(fs) = field_selector {
path.push(sep);
path.push_str("fieldSelector=");
path.push_str(&pct_encode(fs));
}
Self::object(Self::ident("list_selector/", id_tag), HttpMethod::Get, path)
.with_kind(OpKind::List)
}
#[must_use]
pub fn body_bytes(&self) -> Option<Vec<u8>> {
self.body
.as_ref()
.map(|b| serde_json::to_vec(b).unwrap_or_default())
}
}
#[must_use]
pub fn pct_encode(s: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn paths_render_typed() {
assert_eq!(Operation::create_namespace("x").path, "/api/v1/namespaces");
assert_eq!(
Operation::get_configmap("ns", "cm").path,
"/api/v1/namespaces/ns/configmaps/cm"
);
assert_eq!(
Operation::list_configmaps("ns", "cm").path,
"/api/v1/namespaces/ns/configmaps?fieldSelector=metadata.name%3Dcm"
);
}
#[test]
fn replace_carries_verb_probe() {
let op = Operation::replace_configmap("ns", "cm", json!({}));
let (gvr, verb) = op.verb_probe.expect("replace is a verb probe");
assert_eq!(gvr.resource, "configmaps");
assert_eq!(verb, Verb::Update);
}
#[test]
fn patch_variants_carry_the_content_type() {
assert_eq!(
Operation::merge_patch("ns", "configmaps", "cm", json!({})).content_type,
Some("application/merge-patch+json".into())
);
assert_eq!(
Operation::strategic_patch("ns", "pods", "p", json!({})).content_type,
Some("application/strategic-merge-patch+json".into())
);
assert_eq!(
Operation::json_patch("ns", "pods", "p", json!([])).content_type,
Some("application/json-patch+json".into())
);
}
#[test]
fn status_paths_render() {
assert_eq!(
Operation::get_status("ns", "pods", "p").path,
"/api/v1/namespaces/ns/pods/p/status"
);
assert_eq!(
Operation::put_status("ns", "pods", "p", json!({})).path,
"/api/v1/namespaces/ns/pods/p/status"
);
}
#[test]
fn selector_query_is_percent_encoded() {
let op =
Operation::list_selector("ns", "configmaps", "in", Some("tier in (web,api)"), None);
assert_eq!(
op.path,
"/api/v1/namespaces/ns/configmaps?labelSelector=tier%20in%20%28web%2Capi%29"
);
assert_eq!(op.kind, OpKind::List);
let both = Operation::list_selector(
"ns",
"configmaps",
"ne",
Some("a!=b"),
Some("metadata.name=x"),
);
assert_eq!(
both.path,
"/api/v1/namespaces/ns/configmaps?labelSelector=a%21%3Db&fieldSelector=metadata.name%3Dx"
);
}
#[test]
fn focus_is_carried() {
let op = Operation::get("ns", "pods", "p")
.with_focus(JsonPath::parse("spec.containers[0].image"));
assert_eq!(
op.focus.map(|p| p.to_string()),
Some(".spec.containers[0].image".to_string())
);
}
}