//! 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 picloud_shared::{ AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script, }; use reqwest::{header, Method, RequestBuilder, StatusCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::config::Credentials; 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 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 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 } /// `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 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