use super::{PlmRequest, PlmResponse, PlmTransport};
use serde::Deserialize;
use serde_json::{json, Value};
use std::cell::RefCell;
use std::rc::Rc;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) use super::backend::open_session;
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, PartialEq)]
pub enum PlmError {
Unreachable(String),
SignIn(String),
Forbidden(String),
NotFound(String),
Conflict(String),
TooLarge(String),
Throttled(String),
Refused { status: u16, message: String },
Malformed(String),
Version { server: String, min_client: String, client: String, reason: String },
}
impl PlmError {
pub fn status(&self) -> Option<u16> {
match self {
PlmError::SignIn(_) => Some(401),
PlmError::Forbidden(_) => Some(403),
PlmError::NotFound(_) => Some(404),
PlmError::Conflict(_) => Some(409),
PlmError::TooLarge(_) => Some(413),
PlmError::Throttled(_) => Some(429),
PlmError::Refused { status, .. } => Some(*status),
PlmError::Unreachable(_) | PlmError::Malformed(_) | PlmError::Version { .. } => None,
}
}
pub fn from_response(response: &PlmResponse) -> Self {
let sentence = server_sentence(response);
let or = |fallback: &str| if sentence.is_empty() { fallback.to_string() } else { sentence.clone() };
match response.status {
401 => PlmError::SignIn(or("sign in again")),
403 => PlmError::Forbidden(or("you may not do that")),
404 => PlmError::NotFound(or("not found")),
409 => PlmError::Conflict(or("refused: the revision is locked or not editable")),
413 => PlmError::TooLarge(or("too large for the server's limit")),
429 => PlmError::Throttled(or("too many sign-in attempts; wait and try again")),
status => PlmError::Refused { status, message: or(&format!("the server answered {status}")) },
}
}
}
impl std::fmt::Display for PlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlmError::Unreachable(why) => write!(f, "the PLM server did not answer: {why}"),
PlmError::SignIn(s) => write!(f, "sign in again: {s}"),
PlmError::Forbidden(s)
| PlmError::NotFound(s)
| PlmError::Conflict(s)
| PlmError::TooLarge(s)
| PlmError::Throttled(s) => f.write_str(s),
PlmError::Refused { message, .. } => f.write_str(message),
PlmError::Malformed(why) => write!(f, "the PLM server's answer was not understood: {why}"),
PlmError::Version { server, min_client, client, reason } => write!(
f,
"this CAD app ({client}) cannot use this PLM server ({server}, which serves CAD {min_client} and later): {reason}."
),
}
}
}
fn credential_headers(credential: &Credential, method: &str) -> Vec<(String, String)> {
let mut headers = vec![("Accept".to_string(), "application/json".to_string())];
match credential {
Credential::Anonymous => {}
Credential::Token(token) => headers.push(("Authorization".into(), format!("Bearer {token}"))),
Credential::Session { cookie, csrf } => {
if let Some(cookie) = cookie {
headers.push(("Cookie".into(), cookie.clone()));
}
if method != "GET" {
if let Some(csrf) = csrf {
headers.push(("X-CSRF-Token".into(), csrf.clone()));
}
}
}
}
headers
}
fn server_sentence(response: &PlmResponse) -> String {
if let Ok(v) = serde_json::from_slice::<Value>(&response.body) {
if let Some(s) = v.get("error").and_then(Value::as_str) {
return s.to_string();
}
}
String::from_utf8_lossy(&response.body).trim().chars().take(500).collect()
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct Me {
pub id: String,
pub username: String,
pub display_name: String,
pub groups: Vec<String>,
pub via: Option<String>,
pub csrf: Option<String>,
pub server_version: Option<String>,
pub min_client_version: Option<String>,
#[serde(default)]
pub features: Vec<String>,
pub can_author: Option<bool>,
}
impl Me {
pub fn has(&self, feature: &str) -> bool {
self.features.iter().any(|f| f == feature)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Credential {
Anonymous,
Token(String),
Session { cookie: Option<String>, csrf: Option<String> },
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct DesktopLink {
pub link_token: String,
pub poll_secret: String,
pub code: String,
pub path: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LinkPoll {
Pending,
Denied,
Approved(String),
Gone,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct StoreIndex {
pub seq: u64,
pub entries: Vec<IndexEntry>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct IndexEntry {
pub key: String,
pub part_id: String,
pub part_number: String,
pub part_name: String,
pub revision_id: String,
pub revision_label: String,
pub lifecycle: String,
pub editable: bool,
pub content_hash: String,
pub size: u64,
pub modified: i64,
pub locked_by: Option<String>,
pub locked_by_me: bool,
pub document_class: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct PutAck {
pub seq: u64,
pub content_hash: String,
pub size: u64,
pub uses_refused: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct Changes {
pub seq: u64,
pub stale: bool,
pub keys: Vec<String>,
}
pub struct PlmClient {
transport: Rc<dyn PlmTransport>,
credential: RefCell<Credential>,
me: RefCell<Option<Me>>,
sent: std::cell::Cell<u64>,
by_route: RefCell<std::collections::BTreeMap<String, u64>>,
}
pub fn route_of(method: &str, path: &str) -> String {
let path = path.split('?').next().unwrap_or(path);
let segments: Vec<&str> = path.split('/').map(|s| if s.chars().any(|c| c.is_ascii_digit()) { "*" } else { s }).collect();
format!("{method} {}", segments.join("/"))
}
impl PlmClient {
pub fn new(transport: Rc<dyn PlmTransport>) -> Self {
Self {
transport,
credential: RefCell::new(Credential::Anonymous),
me: RefCell::new(None),
sent: std::cell::Cell::new(0),
by_route: RefCell::new(Default::default()),
}
}
pub fn requests_sent(&self) -> u64 {
self.sent.get()
}
pub fn requests_by_route(&self) -> std::collections::BTreeMap<String, u64> {
self.by_route.borrow().clone()
}
fn count(&self, method: &str, path: &str) {
self.sent.set(self.sent.get() + 1);
*self.by_route.borrow_mut().entry(route_of(method, path)).or_default() += 1;
}
pub fn me(&self) -> Option<Me> {
self.me.borrow().clone()
}
pub fn can_author(&self) -> Option<bool> {
self.me.borrow().as_ref().and_then(|me| me.can_author)
}
pub fn credential(&self) -> Credential {
self.credential.borrow().clone()
}
pub fn replace_token(&self, token: &str) {
*self.credential.borrow_mut() = Credential::Token(token.trim().to_string());
}
pub fn forget(&self) {
*self.credential.borrow_mut() = Credential::Anonymous;
*self.me.borrow_mut() = None;
}
pub async fn raw(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
let credential = self.credential.borrow().clone();
self.send_as(&credential, method, path, body).await
}
async fn send_as(
&self,
credential: &Credential,
method: &'static str,
path: &str,
body: Option<Vec<u8>>,
) -> Result<PlmResponse, PlmError> {
let mut headers = credential_headers(credential, method);
if body.as_deref().is_some_and(|b| serde_json::from_slice::<Value>(b).is_ok()) {
headers.push(("Content-Type".into(), "application/json".into()));
}
self.count(method, path);
let request = PlmRequest { method, path: path.to_string(), headers, body: body.unwrap_or_default() };
self.transport.send(request).await.map_err(PlmError::Unreachable)
}
pub(crate) async fn call_typed(
&self,
method: &'static str,
path: &str,
body: Vec<u8>,
content_type: &str,
) -> Result<PlmResponse, PlmError> {
let credential = self.credential.borrow().clone();
let mut headers = credential_headers(&credential, method);
headers.push(("Content-Type".into(), content_type.to_string()));
self.count(method, path);
let request = PlmRequest { method, path: path.to_string(), headers, body };
let response = self.transport.send(request).await.map_err(PlmError::Unreachable)?;
if (200..300).contains(&response.status) {
Ok(response)
} else {
Err(PlmError::from_response(&response))
}
}
pub(crate) async fn call(&self, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
let response = self.raw(method, path, body).await?;
if (200..300).contains(&response.status) {
Ok(response)
} else {
Err(PlmError::from_response(&response))
}
}
async fn call_json<T: for<'de> Deserialize<'de>>(
&self,
method: &'static str,
path: &str,
body: Option<Value>,
) -> Result<T, PlmError> {
let response = self.call(method, path, body.map(|b| serde_json::to_vec(&b).unwrap_or_default())).await?;
parse(&response, path)
}
pub async fn sign_in_with_token(&self, token: &str) -> Result<Me, PlmError> {
let token = token.trim();
if token.is_empty() {
return Err(PlmError::SignIn("paste a token (it starts with plm_)".into()));
}
self.adopt(Credential::Token(token.to_string())).await
}
pub async fn sign_in_with_browser_session(&self) -> Result<Me, PlmError> {
self.adopt(Credential::Session { cookie: None, csrf: None }).await
}
pub async fn sign_in_with_password(&self, username: &str, password: &str) -> Result<Me, PlmError> {
let cookie = self.log_in(username, password).await?;
self.adopt(Credential::Session { cookie, csrf: None }).await
}
pub async fn sign_in_for_token(&self, username: &str, password: &str, token_name: &str) -> Result<(Me, String), PlmError> {
let cookie = self.log_in(username, password).await?;
let session = Credential::Session { cookie, csrf: None };
let me: Me = parse(&self.checked(&session, "GET", "/api/me", None).await?, "/api/me")?;
let session = Credential::Session { cookie: session_cookie(&session), csrf: me.csrf.clone() };
if let Err(refused) = check_version(&me) {
let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
return Err(refused);
}
let body = serde_json::to_vec(&json!({ "name": token_name, "scope": "full" })).unwrap_or_default();
let minted: Value = parse(&self.checked(&session, "POST", "/api/tokens", Some(body)).await?, "/api/tokens")?;
let token = minted
.get("token")
.and_then(Value::as_str)
.ok_or_else(|| PlmError::Malformed("/api/tokens answered without a token".into()))?
.to_string();
let _ = self.send_as(&session, "POST", "/api/logout", Some(b"{}".to_vec())).await;
let me = self.sign_in_with_token(&token).await?;
Ok((me, token))
}
pub async fn register_desktop_link(&self, host: &str, os: &str) -> Result<DesktopLink, PlmError> {
let body = serde_json::to_vec(&json!({ "host": host, "os": os, "app_version": CLIENT_VERSION })).unwrap_or_default();
let path = "/api/desktop/link";
parse(&self.checked(&Credential::Anonymous, "POST", path, Some(body)).await?, path)
}
pub async fn poll_desktop_link(&self, poll_secret: &str) -> Result<LinkPoll, PlmError> {
let body = serde_json::to_vec(&json!({ "poll_secret": poll_secret })).unwrap_or_default();
let path = "/api/desktop/link/poll";
let response = self.send_as(&Credential::Anonymous, "POST", path, Some(body)).await?;
if response.status == 404 {
return Ok(LinkPoll::Gone);
}
if !(200..300).contains(&response.status) {
return Err(PlmError::from_response(&response));
}
let answer: Value = parse(&response, path)?;
match answer.get("status").and_then(Value::as_str) {
Some("pending") => Ok(LinkPoll::Pending),
Some("denied") => Ok(LinkPoll::Denied),
Some("approved") => match answer.get("token").and_then(Value::as_str) {
Some(token) => Ok(LinkPoll::Approved(token.to_string())),
None => Err(PlmError::Malformed(format!("{path} approved without a token"))),
},
other => Err(PlmError::Malformed(format!("{path} answered status {other:?}"))),
}
}
async fn log_in(&self, username: &str, password: &str) -> Result<Option<String>, PlmError> {
let body = serde_json::to_vec(&json!({ "username": username, "password": password })).unwrap_or_default();
let response = self.checked(&Credential::Anonymous, "POST", "/api/login", Some(body)).await?;
Ok(response
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("set-cookie"))
.and_then(|(_, v)| v.split(';').next())
.map(|c| c.trim().to_string()))
}
async fn checked(&self, credential: &Credential, method: &'static str, path: &str, body: Option<Vec<u8>>) -> Result<PlmResponse, PlmError> {
let response = self.send_as(credential, method, path, body).await?;
if (200..300).contains(&response.status) {
Ok(response)
} else {
Err(PlmError::from_response(&response))
}
}
async fn adopt(&self, credential: Credential) -> Result<Me, PlmError> {
let me: Me = parse(&self.checked(&credential, "GET", "/api/me", None).await?, "/api/me")?;
check_version(&me)?;
let credential = match credential {
Credential::Session { cookie, .. } => Credential::Session { cookie, csrf: me.csrf.clone() },
other => other,
};
*self.credential.borrow_mut() = credential;
*self.me.borrow_mut() = Some(me.clone());
Ok(me)
}
pub async fn index(&self) -> Result<StoreIndex, PlmError> {
self.call_json("GET", "/api/store/index", None).await
}
pub async fn index_rows(&self, keys: &[String]) -> Result<StoreIndex, PlmError> {
self.call_json("GET", &format!("/api/store/index?keys={}", keys.iter().map(|k| crate::plm::identity::segment(k)).collect::<Vec<_>>().join(",")), None).await
}
pub async fn get_document(&self, key: &str) -> Result<Option<Vec<u8>>, PlmError> {
let response = self.call("GET", &doc_path(key), None).await?;
Ok(if response.status == 204 { None } else { Some(response.body) })
}
pub async fn put_document(&self, key: &str, bytes: Vec<u8>) -> Result<PutAck, PlmError> {
let path = doc_path(key);
let response = self.call("PUT", &path, Some(bytes)).await?;
parse(&response, &path)
}
pub async fn put_document_if(&self, key: &str, bytes: Vec<u8>, expected: &str) -> Result<PutAck, PlmError> {
let path = doc_path(key);
let credential = self.credential.borrow().clone();
let mut headers = credential_headers(&credential, "PUT");
headers.push(("Content-Type".into(), "application/json".into()));
headers.push(("If-Match".into(), format!("\"{expected}\"")));
self.count("PUT", &path);
let request = PlmRequest { method: "PUT", path: path.clone(), headers, body: bytes };
let response = self.transport.send(request).await.map_err(PlmError::Unreachable)?;
if !(200..300).contains(&response.status) {
return Err(PlmError::from_response(&response));
}
parse(&response, &path)
}
pub async fn delete_document(&self, key: &str) -> Result<(), PlmError> {
self.call("DELETE", &doc_path(key), None).await.map(|_| ())
}
pub async fn get_preference(&self, name: &str) -> Result<Option<String>, PlmError> {
match self.call("GET", &preference_path(name), None).await {
Ok(response) if response.status == 204 => Ok(None),
Ok(response) => String::from_utf8(response.body)
.map(Some)
.map_err(|_| PlmError::Malformed(format!("preference {name} is not UTF-8"))),
Err(PlmError::NotFound(_)) => Ok(None),
Err(e) => Err(e),
}
}
pub async fn put_preference(&self, name: &str, value: String) -> Result<(), PlmError> {
self.call("PUT", &preference_path(name), Some(value.into_bytes())).await.map(|_| ())
}
pub async fn delete_preference(&self, name: &str) -> Result<(), PlmError> {
self.call("DELETE", &preference_path(name), None).await.map(|_| ())
}
pub async fn changes(&self, since: u64) -> Result<Changes, PlmError> {
self.call_json("GET", &format!("/api/store/changes?since={since}"), None).await
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum FeedUpdate {
Quiet,
Keys(Vec<String>),
Reindexed(StoreIndex),
}
#[derive(Debug, Clone, Default)]
pub struct ChangeFeed {
since: Option<u64>,
}
impl ChangeFeed {
pub fn new() -> Self {
Self::default()
}
pub fn starting_at(seq: u64) -> Self {
Self { since: Some(seq) }
}
pub fn position(&self) -> Option<u64> {
self.since
}
pub async fn poll(&mut self, client: &PlmClient) -> Result<FeedUpdate, PlmError> {
let Some(since) = self.since else {
let index = client.index().await?;
self.since = Some(index.seq);
return Ok(FeedUpdate::Reindexed(index));
};
let changes = client.changes(since).await?;
if changes.stale {
let index = client.index().await?;
self.since = Some(index.seq);
return Ok(FeedUpdate::Reindexed(index));
}
self.since = Some(changes.seq.max(since));
let mut keys = Vec::new();
for key in changes.keys {
if !keys.contains(&key) {
keys.push(key);
}
}
Ok(if keys.is_empty() { FeedUpdate::Quiet } else { FeedUpdate::Keys(keys) })
}
}
pub fn check_version(me: &Me) -> Result<(), PlmError> {
check_version_of(me, CLIENT_VERSION)
}
fn check_version_of(me: &Me, client: &str) -> Result<(), PlmError> {
let server = me.server_version.as_deref().unwrap_or("a version it does not report");
let refuse = |min: &str, reason: String| PlmError::Version {
server: server.to_string(),
min_client: min.to_string(),
client: client.to_string(),
reason,
};
let Some(min) = me.min_client_version.as_deref() else {
return Err(refuse("?", "the server does not say which CAD versions it serves; it is older than this app".into()));
};
if !serves(min, client) {
return Err(refuse(min, format!("update this CAD app to {min} or later")));
}
Ok(())
}
pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> {
let core = version.trim().split(['-', '+']).next()?;
let mut fields = core.split('.').map(|f| f.parse::<u64>().ok());
let triple = (fields.next()??, fields.next()??, fields.next()??);
fields.next().is_none().then_some(triple)
}
pub fn serves(min_client_version: &str, client_version: &str) -> bool {
match (parse_version(min_client_version), parse_version(client_version)) {
(Some(min), Some(client)) => client >= min,
_ => false,
}
}
fn preference_path(name: &str) -> String {
format!("/api/me/preferences/{name}")
}
fn doc_path(key: &str) -> String {
format!("/api/store/doc/{}", key.trim_start_matches('/').replace('%', "%25"))
}
fn session_cookie(credential: &Credential) -> Option<String> {
match credential {
Credential::Session { cookie, .. } => cookie.clone(),
_ => None,
}
}
fn parse<T: for<'de> Deserialize<'de>>(response: &PlmResponse, what: &str) -> Result<T, PlmError> {
serde_json::from_slice(&response.body).map_err(|e| PlmError::Malformed(format!("{what}: {e}")))
}