//! Reqwest-backed HTTP client + minimal wire DTOs. //! //! The CLI deliberately re-declares small request/response structs here //! rather than depending on `manager-core` (and pulling its Postgres //! transitive surface). Fields kept to what the CLI actually sends or //! reads. use std::collections::BTreeMap; use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use picloud_shared::{ AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog, Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::config::Credentials; /// Characters that must be escaped inside a single URL **path segment** so /// a free-form value (an app slug, a topic name) can't break out of its /// segment — `/` would start a new segment, `?`/`#` would start the query /// or fragment. Encodes controls, space, and the structural delimiters. const PATH_SEGMENT: &AsciiSet = &CONTROLS .add(b' ') .add(b'/') .add(b'?') .add(b'#') .add(b'%') .add(b'&') .add(b'+'); /// Percent-encode a value for safe interpolation into one path segment. fn seg(s: &str) -> String { utf8_percent_encode(s, PATH_SEGMENT).to_string() } pub struct Client { http: reqwest::Client, url: String, token: String, } impl Client { pub fn from_creds(creds: &Credentials) -> Result { Self::new(&creds.url, &creds.token) } pub fn new(url: &str, token: &str) -> Result { let http = reqwest::Client::builder() .user_agent(concat!("pic/", env!("CARGO_PKG_VERSION"))) .build() .context("building HTTP client")?; Ok(Self { http, url: url.trim_end_matches('/').to_string(), token: token.to_string(), }) } #[allow(dead_code)] // used by the trailing-slash unit test below. pub fn url(&self) -> &str { &self.url } fn request(&self, method: Method, path: &str) -> RequestBuilder { self.http .request(method, format!("{}{path}", self.url)) .header(header::AUTHORIZATION, format!("Bearer {}", self.token)) } /// `GET /api/v1/admin/auth/me` pub async fn auth_me(&self) -> Result { let resp = self .request(Method::GET, "/api/v1/admin/auth/me") .send() .await?; decode(resp).await } /// `GET /api/v1/admin/apps` pub async fn apps_list(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/apps") .send() .await?; decode(resp).await } /// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted. pub async fn apps_get(&self, ident: &str) -> Result { let ident = seg(ident); let resp = self .request(Method::GET, &format!("/api/v1/admin/apps/{ident}")) .send() .await?; decode(resp).await } /// `POST /api/v1/admin/apps` pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result { let resp = self .request(Method::POST, "/api/v1/admin/apps") .json(body) .send() .await?; decode(resp).await } /// `GET /api/v1/admin/scripts?app={ident}` pub async fn scripts_list_by_app(&self, ident: &str) -> Result> { let resp = self .request( Method::GET, &format!("/api/v1/admin/scripts?app={}", urlencoded(ident)), ) .send() .await?; decode(resp).await } /// `GET /api/v1/admin/scripts` — every script the caller can see /// (server filters by membership for `Member`). Lets `pic scripts ls` /// (no `--app`) collapse what used to be an N+1 per-app walk into a /// single request that can't be partially-broken by a concurrent app /// delete. pub async fn scripts_list_all(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/scripts") .send() .await?; decode(resp).await } /// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`. /// Server requires `AppAdmin` capability; without `force`, returns /// 409 if the app still has scripts. pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> { let ident = seg(ident); let path = if force { format!("/api/v1/admin/apps/{ident}?force=true") } else { format!("/api/v1/admin/apps/{ident}") }; let resp = self.request(Method::DELETE, &path).send().await?; decode_status(resp).await } // --- Groups (Phase 2) ------------------------------------------------- /// `GET /api/v1/admin/groups` — the full flat list (assemble the tree /// client-side from `parent_id`). Ignores the §7 `owner` field the server /// also returns (see [`Self::groups_list_with_owner`]). pub async fn groups_list(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/groups") .send() .await?; decode(resp).await } /// Same endpoint as [`Self::groups_list`], but captures the §7 owning /// project's slug alongside each group — backs `pic groups ls`' owner /// column. pub async fn groups_list_with_owner(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/groups") .send() .await?; decode(resp).await } /// `GET /api/v1/admin/projects` — every §7 project + its owned-group count. /// Backs `pic projects ls`. pub async fn projects_list(&self) -> Result> { let resp = self .request(Method::GET, "/api/v1/admin/projects") .send() .await?; decode(resp).await } /// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children. pub async fn groups_get(&self, ident: &str) -> Result { let ident = seg(ident); let resp = self .request(Method::GET, &format!("/api/v1/admin/groups/{ident}")) .send() .await?; decode(resp).await } /// `POST /api/v1/admin/groups` pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result { let resp = self .request(Method::POST, "/api/v1/admin/groups") .json(body) .send() .await?; decode(resp).await } /// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only /// (the slug is frozen). pub async fn groups_rename( &self, ident: &str, name: Option<&str>, description: Option<&str>, ) -> Result { let ident = seg(ident); let body = serde_json::json!({ "name": name, "description": description }); let resp = self .request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}")) .json(&body) .send() .await?; decode(resp).await } /// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a /// slug/id, or `None` to move to root. pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result { let ident = seg(ident); let body = serde_json::json!({ "parent": parent }); let resp = self .request( Method::POST, &format!("/api/v1/admin/groups/{ident}/reparent"), ) .json(&body) .send() .await?; decode(resp).await } /// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty. pub async fn groups_delete(&self, ident: &str) -> Result<()> { let ident = seg(ident); let resp = self .request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}")) .send() .await?; decode_status(resp).await } pub async fn group_members_list(&self, group: &str) -> Result> { let group = seg(group); let resp = self .request( Method::GET, &format!("/api/v1/admin/groups/{group}/members"), ) .send() .await?; decode(resp).await } pub async fn group_members_grant( &self, group: &str, user_id: &str, role: AppRole, ) -> Result { let group = seg(group); let body = serde_json::json!({ "user_id": user_id, "role": role }); let resp = self .request( Method::POST, &format!("/api/v1/admin/groups/{group}/members"), ) .json(&body) .send() .await?; decode(resp).await } pub async fn group_members_set_role( &self, group: &str, user_id: &str, role: AppRole, ) -> Result { let (group, user_id) = (seg(group), seg(user_id)); let body = serde_json::json!({ "role": role }); let resp = self .request( Method::PATCH, &format!("/api/v1/admin/groups/{group}/members/{user_id}"), ) .json(&body) .send() .await?; decode(resp).await } pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> { let (group, user_id) = (seg(group), seg(user_id)); let resp = self .request( Method::DELETE, &format!("/api/v1/admin/groups/{group}/members/{user_id}"), ) .send() .await?; decode_status(resp).await } /// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the /// owning app (stricter than the edit endpoints, by design). pub async fn scripts_delete(&self, id: &str) -> Result<()> { let id = seg(id); let resp = self .request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}")) .send() .await?; decode_status(resp).await } /// `POST /api/v1/admin/scripts` pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result