Merge branch 'work/T-552-ticket-relation-projection' into hare/develop
This commit is contained in:
Generated
+1
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
manifest = { workspace = true }
|
manifest = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,768 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use reqwest::{Method, StatusCode, Url, redirect};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::env;
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const TOKEN_FILE_NAME: &str = "backend-tokens.json";
|
||||||
|
const MAX_REDIRECTS: usize = 10;
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct BackendOrigin(String);
|
||||||
|
|
||||||
|
impl BackendOrigin {
|
||||||
|
pub fn parse(input: &str) -> Result<Self, BackendApiClientError> {
|
||||||
|
let url = Url::parse(input.trim()).map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidBackendOrigin(format!(
|
||||||
|
"Backend URL is not a valid absolute URL: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !url.path().bytes().all(|byte| byte == b'/')
|
||||||
|
|| url.query().is_some()
|
||||||
|
|| url.fragment().is_some()
|
||||||
|
{
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must contain only an origin, without a path, query, or fragment"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Self::from_url(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_url(mut url: Url) -> Result<Self, BackendApiClientError> {
|
||||||
|
if !matches!(url.scheme(), "http" | "https") {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL scheme must be http or https".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !url.username().is_empty() || url.password().is_some() {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must not contain user information".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if url.host().is_none() {
|
||||||
|
return Err(BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL must contain a host".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let default_port = match url.scheme() {
|
||||||
|
"http" => 80,
|
||||||
|
"https" => 443,
|
||||||
|
_ => unreachable!("validated Backend URL scheme"),
|
||||||
|
};
|
||||||
|
if url.port() == Some(default_port) {
|
||||||
|
url.set_port(None).map_err(|()| {
|
||||||
|
BackendApiClientError::InvalidBackendOrigin(
|
||||||
|
"Backend URL contains an invalid port".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
url.set_path("");
|
||||||
|
url.set_query(None);
|
||||||
|
url.set_fragment(None);
|
||||||
|
let normalized = url.as_str().trim_end_matches('/').to_string();
|
||||||
|
Ok(Self(normalized))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn url(&self, path_and_query: &str) -> Result<Url, BackendApiClientError> {
|
||||||
|
if !path_and_query.starts_with('/') || path_and_query.starts_with("//") {
|
||||||
|
return Err(BackendApiClientError::InvalidRequestPath(
|
||||||
|
"Backend API request path must start with one `/`".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| {
|
||||||
|
BackendApiClientError::InvalidRequestPath(format!(
|
||||||
|
"Backend API request path is invalid: {error}"
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendOrigin {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_tuple("BackendOrigin").field(&self.0).finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendOrigin {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct BackendAccessToken(String);
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendAccessToken {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("BackendAccessToken([REDACTED])")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct BackendApiClient {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
access_token: BackendAccessToken,
|
||||||
|
asynchronous: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for BackendApiClient {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("BackendApiClient")
|
||||||
|
.field("origin", &self.origin)
|
||||||
|
.field("access_token", &self.access_token)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BackendApiClient {
|
||||||
|
pub fn from_stored_token(base_url: &str) -> Result<Self, BackendApiClientError> {
|
||||||
|
let path = backend_token_file_path()?;
|
||||||
|
Self::from_token_file(base_url, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
|
||||||
|
let origin = BackendOrigin::parse(base_url)?;
|
||||||
|
let token_file = read_token_file(path)?;
|
||||||
|
let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| {
|
||||||
|
BackendApiClientError::TokenEntryMissing {
|
||||||
|
origin: origin.clone(),
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
validate_token_entry(entry, &origin, path)?;
|
||||||
|
Self::new(origin, BackendAccessToken(entry.access_token.clone()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(
|
||||||
|
origin: BackendOrigin,
|
||||||
|
access_token: BackendAccessToken,
|
||||||
|
) -> Result<Self, BackendApiClientError> {
|
||||||
|
let asynchronous = reqwest::Client::builder()
|
||||||
|
.redirect(redirect_policy(origin.clone()))
|
||||||
|
.build()
|
||||||
|
.map_err(BackendApiClientError::Http)?;
|
||||||
|
Ok(Self {
|
||||||
|
origin,
|
||||||
|
access_token,
|
||||||
|
asynchronous,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn origin(&self) -> &BackendOrigin {
|
||||||
|
&self.origin
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn request(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> Result<reqwest::RequestBuilder, BackendApiClientError> {
|
||||||
|
let url = self.origin.url(path_and_query)?;
|
||||||
|
Ok(self
|
||||||
|
.asynchronous
|
||||||
|
.request(method, url)
|
||||||
|
.bearer_auth(&self.access_token.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn blocking_request(
|
||||||
|
&self,
|
||||||
|
method: Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
) -> Result<reqwest::blocking::RequestBuilder, BackendApiClientError> {
|
||||||
|
let url = self.origin.url(path_and_query)?;
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.redirect(redirect_policy(self.origin.clone()))
|
||||||
|
.build()
|
||||||
|
.map_err(BackendApiClientError::Http)?;
|
||||||
|
Ok(client
|
||||||
|
.request(method, url)
|
||||||
|
.bearer_auth(&self.access_token.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn authorization_header_value(&self) -> String {
|
||||||
|
format!("Bearer {}", self.access_token.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
||||||
|
match status {
|
||||||
|
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
}),
|
||||||
|
StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
}),
|
||||||
|
status if !status.is_success() => Err(BackendApiClientError::BackendStatus {
|
||||||
|
origin: self.origin.clone(),
|
||||||
|
status: status.as_u16(),
|
||||||
|
}),
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn from_access_token_for_test(
|
||||||
|
base_url: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<Self, BackendApiClientError> {
|
||||||
|
Self::new(
|
||||||
|
BackendOrigin::parse(base_url)?,
|
||||||
|
BackendAccessToken(access_token.to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
||||||
|
redirect::Policy::custom(move |attempt| {
|
||||||
|
if attempt.previous().len() >= MAX_REDIRECTS {
|
||||||
|
return attempt.error("Backend request exceeded the redirect limit");
|
||||||
|
}
|
||||||
|
match BackendOrigin::from_url(attempt.url().clone()) {
|
||||||
|
Ok(target_origin) if target_origin == origin => attempt.follow(),
|
||||||
|
Ok(target_origin) => attempt.error(format!(
|
||||||
|
"Backend request refused a cross-origin redirect from {origin} to {target_origin}"
|
||||||
|
)),
|
||||||
|
Err(error) => attempt.error(error.to_string()),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BackendApiClientError {
|
||||||
|
InvalidBackendOrigin(String),
|
||||||
|
InvalidRequestPath(String),
|
||||||
|
ConfigDirectoryUnavailable,
|
||||||
|
TokenFileMissing {
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
TokenFileMalformed {
|
||||||
|
path: PathBuf,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
TokenEntryMissing {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
path: PathBuf,
|
||||||
|
},
|
||||||
|
TokenExpired {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
expired_at: String,
|
||||||
|
},
|
||||||
|
Http(reqwest::Error),
|
||||||
|
Unauthorized {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
},
|
||||||
|
Forbidden {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
},
|
||||||
|
BackendStatus {
|
||||||
|
origin: BackendOrigin,
|
||||||
|
status: u16,
|
||||||
|
},
|
||||||
|
Io {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for BackendApiClientError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => {
|
||||||
|
f.write_str(message)
|
||||||
|
}
|
||||||
|
Self::ConfigDirectoryUnavailable => f.write_str(
|
||||||
|
"cannot locate the client configuration directory for backend-tokens.json",
|
||||||
|
),
|
||||||
|
Self::TokenFileMissing { path } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token file {} is missing; run `yoi login --backend <BACKEND>` first",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenFileMalformed { path, message } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token file {} is malformed: {message}; run `yoi login --backend <BACKEND>` again",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenEntryMissing { origin, path } => write!(
|
||||||
|
f,
|
||||||
|
"no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
Self::TokenExpired { origin, expired_at } => write!(
|
||||||
|
f,
|
||||||
|
"Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again"
|
||||||
|
),
|
||||||
|
Self::Http(error) => write!(f, "Backend request failed: {error}"),
|
||||||
|
Self::Unauthorized { origin } => write!(
|
||||||
|
f,
|
||||||
|
"Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again"
|
||||||
|
),
|
||||||
|
Self::Forbidden { origin } => write!(
|
||||||
|
f,
|
||||||
|
"Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation"
|
||||||
|
),
|
||||||
|
Self::BackendStatus { origin, status } => {
|
||||||
|
write!(f, "Backend {origin} returned HTTP {status}")
|
||||||
|
}
|
||||||
|
Self::Io { path, source } => {
|
||||||
|
write!(f, "failed to access {}: {source}", path.display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for BackendApiClientError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Http(error) => Some(error),
|
||||||
|
Self::Io { source, .. } => Some(source),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BackendTokenFile {
|
||||||
|
tokens: BTreeMap<String, BackendTokenEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct BackendTokenEntry {
|
||||||
|
token_type: String,
|
||||||
|
access_token: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_backend_token(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
save_backend_token_with_expiry(base_url, token_type, access_token, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_backend_token_with_expiry(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
expires_at: Option<String>,
|
||||||
|
) -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
let path = backend_token_file_path()?;
|
||||||
|
save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?;
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_backend_token_to_file(
|
||||||
|
base_url: &str,
|
||||||
|
token_type: &str,
|
||||||
|
access_token: &str,
|
||||||
|
expires_at: Option<String>,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
let origin = BackendOrigin::parse(base_url)?;
|
||||||
|
let mut token_file = if path.exists() {
|
||||||
|
read_token_file(&path)?
|
||||||
|
} else {
|
||||||
|
BackendTokenFile {
|
||||||
|
tokens: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let entry = BackendTokenEntry {
|
||||||
|
token_type: token_type.to_string(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
validate_token_entry(&entry, &origin, path)?;
|
||||||
|
token_file.tokens.insert(origin.to_string(), entry);
|
||||||
|
write_token_file(path, &token_file)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn backend_token_file_path() -> Result<PathBuf, BackendApiClientError> {
|
||||||
|
if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME));
|
||||||
|
}
|
||||||
|
let Some(home) = env::var_os("HOME") else {
|
||||||
|
return Err(BackendApiClientError::ConfigDirectoryUnavailable);
|
||||||
|
};
|
||||||
|
Ok(PathBuf::from(home)
|
||||||
|
.join(".config")
|
||||||
|
.join("yoi")
|
||||||
|
.join(TOKEN_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_token_file(path: &Path) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||||
|
let bytes = fs::read(path).map_err(|source| {
|
||||||
|
if source.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
BackendApiClientError::TokenFileMissing {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
BackendApiClientError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
normalize_token_file(raw, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_token_file(
|
||||||
|
token_file: BackendTokenFile,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<BackendTokenFile, BackendApiClientError> {
|
||||||
|
let mut normalized = BTreeMap::new();
|
||||||
|
for (raw_origin, entry) in token_file.tokens {
|
||||||
|
let origin = BackendOrigin::parse(&raw_origin).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token key `{raw_origin}` is invalid: {error}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if normalized.insert(origin.to_string(), entry).is_some() {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("more than one token entry normalizes to `{origin}`"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(BackendTokenFile { tokens: normalized })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_token_entry(
|
||||||
|
entry: &BackendTokenEntry,
|
||||||
|
origin: &BackendOrigin,
|
||||||
|
path: &Path,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
if !entry.token_type.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` does not use the Bearer token type"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if entry.access_token.trim().is_empty()
|
||||||
|
|| entry.access_token.contains('\r')
|
||||||
|
|| entry.access_token.contains('\n')
|
||||||
|
{
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` is empty or contains an invalid line break"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() {
|
||||||
|
return Err(BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` cannot be represented as an HTTP header"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if let Some(expires_at) = entry.expires_at.as_deref() {
|
||||||
|
let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: format!("token for `{origin}` has invalid expires_at: {error}"),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if expiration <= Utc::now() {
|
||||||
|
return Err(BackendApiClientError::TokenExpired {
|
||||||
|
origin: origin.clone(),
|
||||||
|
expired_at: expires_at.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_token_file(
|
||||||
|
path: &Path,
|
||||||
|
token_file: &BackendTokenFile,
|
||||||
|
) -> Result<(), BackendApiClientError> {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?;
|
||||||
|
fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
let payload = serde_json::to_vec_pretty(token_file).map_err(|error| {
|
||||||
|
BackendApiClientError::TokenFileMalformed {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id()));
|
||||||
|
let mut options = OpenOptions::new();
|
||||||
|
options.write(true).create(true).truncate(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
let mut file = options
|
||||||
|
.open(&temp_path)
|
||||||
|
.map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: temp_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
file.write_all(&payload)
|
||||||
|
.and_then(|()| file.write_all(b"\n"))
|
||||||
|
.and_then(|()| file.sync_all())
|
||||||
|
.map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: temp_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
fn temp_path(label: &str) -> PathBuf {
|
||||||
|
let nonce = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
env::temp_dir().join(format!(
|
||||||
|
"yoi-client-{label}-{}-{nonce}.json",
|
||||||
|
std::process::id()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_fixture(path: &Path, value: serde_json::Value) {
|
||||||
|
fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_origin_normalizes_safe_equivalents() {
|
||||||
|
let variants = [
|
||||||
|
"HTTP://Example.COM",
|
||||||
|
"http://example.com/",
|
||||||
|
"http://example.com:80////",
|
||||||
|
];
|
||||||
|
for variant in variants {
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse(variant).unwrap().as_str(),
|
||||||
|
"http://example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse("https://EXAMPLE.com:443/")
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"https://example.com"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
BackendOrigin::parse("https://example.com:8443/")
|
||||||
|
.unwrap()
|
||||||
|
.as_str(),
|
||||||
|
"https://example.com:8443"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_origin_rejects_unsafe_authority_changes() {
|
||||||
|
for invalid in [
|
||||||
|
"ftp://example.com",
|
||||||
|
"https://user@example.com",
|
||||||
|
"https://example.com/api",
|
||||||
|
"https://example.com/?query=1",
|
||||||
|
"https://example.com/#fragment",
|
||||||
|
] {
|
||||||
|
assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}");
|
||||||
|
}
|
||||||
|
assert_ne!(
|
||||||
|
BackendOrigin::parse("http://localhost:8787").unwrap(),
|
||||||
|
BackendOrigin::parse("http://127.0.0.1:8787").unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() {
|
||||||
|
let missing = temp_path("missing");
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &missing),
|
||||||
|
Err(BackendApiClientError::TokenFileMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let malformed = temp_path("malformed");
|
||||||
|
fs::write(&malformed, b"not json").unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &malformed),
|
||||||
|
Err(BackendApiClientError::TokenFileMalformed { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let mismatch = temp_path("mismatch");
|
||||||
|
write_fixture(
|
||||||
|
&mismatch,
|
||||||
|
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||||
|
"token_type": "Bearer", "access_token": "secret"
|
||||||
|
}}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch),
|
||||||
|
Err(BackendApiClientError::TokenEntryMissing { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
let expired = temp_path("expired");
|
||||||
|
write_fixture(
|
||||||
|
&expired,
|
||||||
|
serde_json::json!({"tokens": {"http://localhost:8787": {
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"access_token": "secret",
|
||||||
|
"expires_at": "2000-01-01T00:00:00Z"
|
||||||
|
}}}),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
BackendApiClient::from_token_file("http://localhost:8787", &expired),
|
||||||
|
Err(BackendApiClientError::TokenExpired { .. })
|
||||||
|
));
|
||||||
|
|
||||||
|
for path in [malformed, mismatch, expired] {
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_write_and_lookup_share_origin_normalization() {
|
||||||
|
let path = temp_path("normalized-write");
|
||||||
|
save_backend_token_to_file(
|
||||||
|
"HTTP://Example.COM:80////",
|
||||||
|
"Bearer",
|
||||||
|
"normalized-secret",
|
||||||
|
None,
|
||||||
|
&path,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(contents.contains("\"http://example.com\""));
|
||||||
|
let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.authorization_header_value(),
|
||||||
|
"Bearer normalized-secret"
|
||||||
|
);
|
||||||
|
fs::remove_file(path).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_debug_and_errors_never_include_token_value() {
|
||||||
|
let client = BackendApiClient::from_access_token_for_test(
|
||||||
|
"http://localhost:8787",
|
||||||
|
"never-print-this-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!format!("{client:?}").contains("never-print-this-token"));
|
||||||
|
assert!(
|
||||||
|
!BackendApiClientError::Unauthorized {
|
||||||
|
origin: client.origin().clone()
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
.contains("never-print-this-token")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_requests_follow_only_same_origin_redirects() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let origin = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
for response in [
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||||
|
] {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap();
|
||||||
|
let response = client
|
||||||
|
.blocking_request(Method::GET, "/start")
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() {
|
||||||
|
let source = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let target = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
target.set_nonblocking(true).unwrap();
|
||||||
|
let source_origin = format!("http://{}", source.local_addr().unwrap());
|
||||||
|
let target_origin = format!("http://{}", target.local_addr().unwrap());
|
||||||
|
let location = format!("{target_origin}/capture");
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = source.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.contains("authorization: bearer redirect-secret\r\n"));
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).unwrap();
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret")
|
||||||
|
.unwrap();
|
||||||
|
let error = client
|
||||||
|
.blocking_request(Method::GET, "/start")
|
||||||
|
.unwrap()
|
||||||
|
.send()
|
||||||
|
.unwrap_err();
|
||||||
|
let message = error.to_string();
|
||||||
|
assert!(message.contains("redirect"));
|
||||||
|
assert!(!message.contains("redirect-secret"));
|
||||||
|
handle.join().unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(20));
|
||||||
|
assert!(matches!(
|
||||||
|
target.accept(),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_diagnostics_distinguish_unauthorized_and_forbidden() {
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret")
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
client.check_status(StatusCode::UNAUTHORIZED),
|
||||||
|
Err(BackendApiClientError::Unauthorized { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
client.check_status(StatusCode::FORBIDDEN),
|
||||||
|
Err(BackendApiClientError::Forbidden { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::BackendOrigin;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -9,9 +10,11 @@ pub struct BackendAuthTarget {
|
|||||||
|
|
||||||
impl BackendAuthTarget {
|
impl BackendAuthTarget {
|
||||||
pub fn new(base_url: impl Into<String>) -> Self {
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
Self {
|
let base_url = base_url.into();
|
||||||
base_url: base_url.into(),
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
}
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
|
Self { base_url }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn api_url(&self, path: &str) -> String {
|
fn api_url(&self, path: &str) -> String {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
use futures::{SinkExt, StreamExt};
|
use futures::{SinkExt, StreamExt};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
use protocol::stream::{decode_event, encode_method};
|
||||||
use protocol::{ErrorCode, Event, Method};
|
use protocol::{ErrorCode, Event, Method};
|
||||||
|
use reqwest::Method as HttpMethod;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio_tungstenite::connect_async;
|
use tokio_tungstenite::connect_async;
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
|
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||||
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary;
|
||||||
pub use workspace_api::{
|
pub use workspace_api::{
|
||||||
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity,
|
||||||
@@ -113,6 +118,7 @@ pub struct BackendRuntimeClient {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendRuntimeClientError {
|
pub enum BackendRuntimeClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendRuntimeClientError {}
|
impl std::error::Error for BackendRuntimeClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendRuntimeClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendRuntimeClientError {
|
impl From<reqwest::Error> for BackendRuntimeClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
|
|||||||
target: &BackendRuntimeListTarget,
|
target: &BackendRuntimeListTarget,
|
||||||
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, BackendRuntimeClientError> {
|
||||||
validate_list_target(target)?;
|
validate_list_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
if let Some(runtime_id) = target.runtime_id.as_deref() {
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
@@ -146,12 +159,9 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api.request(HttpMethod::GET, &path)?.send().await?;
|
||||||
return Ok(http
|
api.check_status(response.status())?;
|
||||||
.get(url)
|
return Ok(response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?);
|
.await?);
|
||||||
}
|
}
|
||||||
@@ -162,12 +172,9 @@ pub async fn list_backend_workers(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
);
|
);
|
||||||
let runtime_url = join_base_and_path(&target.base_url, &runtime_path);
|
let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?;
|
||||||
let runtimes = http
|
api.check_status(response.status())?;
|
||||||
.get(runtime_url)
|
let runtimes = response
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.error_for_status()?
|
|
||||||
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
.json::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -181,29 +188,43 @@ pub async fn list_backend_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
&runtime.runtime_id,
|
&runtime.runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = match api.request(HttpMethod::GET, &path)?.send().await {
|
||||||
match http
|
Ok(response) => response,
|
||||||
.get(url)
|
Err(error) => {
|
||||||
.send()
|
diagnostics.push(BackendDiagnostic {
|
||||||
.await
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
.and_then(|response| response.error_for_status())
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
{
|
message: format!(
|
||||||
Ok(response) => {
|
"failed to list workers for runtime {}: {error}",
|
||||||
let response = response
|
runtime.runtime_id
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
),
|
||||||
.await?;
|
});
|
||||||
diagnostics.extend(response.diagnostics);
|
continue;
|
||||||
items.extend(response.items);
|
|
||||||
}
|
}
|
||||||
Err(error) => diagnostics.push(BackendDiagnostic {
|
};
|
||||||
|
if matches!(
|
||||||
|
response.status(),
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
) {
|
||||||
|
api.check_status(response.status())?;
|
||||||
|
}
|
||||||
|
if !response.status().is_success() {
|
||||||
|
diagnostics.push(BackendDiagnostic {
|
||||||
code: "runtime_worker_list_failed".to_string(),
|
code: "runtime_worker_list_failed".to_string(),
|
||||||
severity: BackendDiagnosticSeverity::Error,
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
message: format!(
|
message: format!(
|
||||||
"failed to list workers for runtime {}: {error}",
|
"failed to list workers for runtime {}: Backend returned HTTP {}",
|
||||||
runtime.runtime_id
|
runtime.runtime_id,
|
||||||
|
response.status().as_u16()
|
||||||
),
|
),
|
||||||
}),
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let response = response
|
||||||
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
|
.await?;
|
||||||
|
diagnostics.extend(response.diagnostics);
|
||||||
|
items.extend(response.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(BackendRuntimeListResponse {
|
Ok(BackendRuntimeListResponse {
|
||||||
@@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers(
|
|||||||
"stopped worker listing requires a runtime id".to_string(),
|
"stopped worker listing requires a runtime id".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_workers_path(
|
let path = backend_runtime_workers_path(
|
||||||
target
|
target
|
||||||
.workspace_id
|
.workspace_id
|
||||||
@@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers(
|
|||||||
.expect("validated Backend Workspace scope"),
|
.expect("validated Backend Workspace scope"),
|
||||||
runtime_id,
|
runtime_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped"));
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::GET, &format!("{path}?status=stopped"))?
|
||||||
.get(url)
|
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
|
Ok(response
|
||||||
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
.json::<BackendRuntimeListResponse<BackendWorkerSummary>>()
|
||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
|
|||||||
target: &BackendRuntimeTarget,
|
target: &BackendRuntimeTarget,
|
||||||
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
|
||||||
validate_target(target)?;
|
validate_target(target)?;
|
||||||
let http = reqwest::Client::new();
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let path = backend_runtime_worker_restore_path(
|
let path = backend_runtime_worker_restore_path(
|
||||||
&target.workspace_id,
|
&target.workspace_id,
|
||||||
&target.runtime_id,
|
&target.runtime_id,
|
||||||
&target.worker_id,
|
&target.worker_id,
|
||||||
);
|
);
|
||||||
let url = join_base_and_path(&target.base_url, &path);
|
let response = api
|
||||||
Ok(http
|
.request(HttpMethod::POST, &path)?
|
||||||
.post(url)
|
|
||||||
.json(&serde_json::json!({}))
|
.json(&serde_json::json!({}))
|
||||||
.send()
|
.send()
|
||||||
.await?
|
.await?;
|
||||||
.error_for_status()?
|
api.check_status(response.status())?;
|
||||||
.json::<BackendWorkerRestoreResponse>()
|
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendRuntimeClient {
|
impl BackendRuntimeClient {
|
||||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
||||||
validate_target(&target)?;
|
validate_target(&target)?;
|
||||||
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
let (event_tx, rx) = mpsc::unbounded_channel();
|
||||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
let protocol_target = target.clone();
|
let protocol_target = target.clone();
|
||||||
let protocol_event_tx = event_tx.clone();
|
let protocol_event_tx = event_tx.clone();
|
||||||
let protocol_task = tokio::spawn(async move {
|
let protocol_task = tokio::spawn(async move {
|
||||||
run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await;
|
run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
|
||||||
|
.await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
@@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient {
|
|||||||
|
|
||||||
async fn run_worker_protocol_transport(
|
async fn run_worker_protocol_transport(
|
||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
|
api: BackendApiClient,
|
||||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
mut commands: mpsc::UnboundedReceiver<Method>,
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
tx: mpsc::UnboundedSender<Event>,
|
||||||
) {
|
) {
|
||||||
let url = protocol_ws_url(&target);
|
let request = match protocol_ws_request(&target, &api) {
|
||||||
match connect_async(&url).await {
|
Ok(request) => request,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
|
"Backend protocol request could not be constructed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match connect_async(request).await {
|
||||||
Ok((ws, _)) => {
|
Ok((ws, _)) => {
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
loop {
|
loop {
|
||||||
@@ -387,10 +418,8 @@ async fn run_worker_protocol_transport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let message = protocol_connect_error_message(&target, &api, &error);
|
||||||
"Backend protocol WebSocket connect failed for {}: {error}",
|
let _ = tx.send(diagnostic_event(message));
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
while commands.recv().await.is_some() {
|
while commands.recv().await.is_some() {
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
let _ = tx.send(diagnostic_event(format!(
|
||||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
"Backend protocol command was not sent because command stream is unavailable for {}",
|
||||||
@@ -401,6 +430,29 @@ async fn run_worker_protocol_transport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_connect_error_message(
|
||||||
|
target: &BackendRuntimeTarget,
|
||||||
|
api: &BackendApiClient,
|
||||||
|
error: &tokio_tungstenite::tungstenite::Error,
|
||||||
|
) -> String {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = error {
|
||||||
|
if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) {
|
||||||
|
if matches!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN
|
||||||
|
) {
|
||||||
|
if let Err(error) = api.check_status(status) {
|
||||||
|
return error.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Backend protocol WebSocket connect failed for {}: {error}",
|
||||||
|
target.display_label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn diagnostic_event(message: impl Into<String>) -> Event {
|
fn diagnostic_event(message: impl Into<String>) -> Event {
|
||||||
Event::Error {
|
Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
@@ -496,6 +548,19 @@ fn backend_runtime_worker_restore_path(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn protocol_ws_request(
|
||||||
|
target: &BackendRuntimeTarget,
|
||||||
|
api: &BackendApiClient,
|
||||||
|
) -> Result<tokio_tungstenite::tungstenite::http::Request<()>, String> {
|
||||||
|
let mut request = protocol_ws_url(target)
|
||||||
|
.into_client_request()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
let value = HeaderValue::from_str(&api.authorization_header_value())
|
||||||
|
.map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?;
|
||||||
|
request.headers_mut().insert(AUTHORIZATION, value);
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
fn protocol_ws_url(target: &BackendRuntimeTarget) -> String {
|
||||||
let path = format!(
|
let path = format!(
|
||||||
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
"/api/w/{}/runtimes/{}/workers/{}/protocol/ws",
|
||||||
@@ -557,6 +622,26 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn protocol_request_attaches_saved_bearer_authorization() {
|
||||||
|
let target = BackendRuntimeTarget::new(
|
||||||
|
"http://127.0.0.1:8787/",
|
||||||
|
"workspace alpha",
|
||||||
|
"runtime/one",
|
||||||
|
"worker one",
|
||||||
|
);
|
||||||
|
let api = BackendApiClient::from_access_token_for_test(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"websocket-secret",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let request = protocol_ws_request(&target, &api).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
request.headers().get(AUTHORIZATION).unwrap(),
|
||||||
|
"Bearer websocket-secret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
fn backend_worker_summary_decodes_current_occupied_workdir_contract() {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
use workspace_api::{RepositoryObservedStatus, RepositorySource};
|
||||||
@@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendWorkspaceClientError {
|
pub enum BackendWorkspaceClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
RequestFailed { status: u16, message: String },
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::RequestFailed { status, message } => {
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
write!(f, "Backend request failed with HTTP {status}: {message}")
|
|
||||||
}
|
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError {
|
|||||||
|
|
||||||
impl std::error::Error for BackendWorkspaceClientError {}
|
impl std::error::Error for BackendWorkspaceClientError {}
|
||||||
|
|
||||||
|
impl From<BackendApiClientError> for BackendWorkspaceClientError {
|
||||||
|
fn from(error: BackendApiClientError) -> Self {
|
||||||
|
Self::Api(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
impl From<reqwest::Error> for BackendWorkspaceClientError {
|
||||||
fn from(error: reqwest::Error) -> Self {
|
fn from(error: reqwest::Error) -> Self {
|
||||||
Self::Http(error)
|
Self::Http(error)
|
||||||
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!(
|
list_backend_workspaces_with_client(&client).await
|
||||||
"{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}",
|
}
|
||||||
target.base_url.trim_end_matches('/')
|
|
||||||
);
|
async fn list_backend_workspaces_with_client(
|
||||||
let response = reqwest::Client::new().get(url).send().await?;
|
client: &BackendApiClient,
|
||||||
let response = require_success(response).await?;
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
let response = client
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||||
|
)?
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
Ok(response.json::<Vec<BackendWorkspace>>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
|
|||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
request: &CreateBackendWorkspaceRequest,
|
request: &CreateBackendWorkspaceRequest,
|
||||||
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
|
||||||
validate_target(target)?;
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/'));
|
let response = client
|
||||||
let response = reqwest::Client::new()
|
.request(Method::POST, "/api/workspaces")?
|
||||||
.post(url)
|
|
||||||
.json(request)
|
.json(request)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let response = require_success(response).await?;
|
client.check_status(response.status())?;
|
||||||
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
Ok(response.json::<CreateBackendWorkspaceResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn require_success(
|
|
||||||
response: reqwest::Response,
|
|
||||||
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response.text().await.unwrap_or_default();
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_target(
|
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
|
||||||
if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) {
|
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend API base URL must start with http:// or https://".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn workspace_catalog_request_uses_shared_bearer_client() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let handle = thread::spawn(move || {
|
||||||
|
let (mut stream, _) = listener.accept().unwrap();
|
||||||
|
let mut request = vec![0; 4096];
|
||||||
|
let read = stream.read(&mut request).unwrap();
|
||||||
|
let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase();
|
||||||
|
assert!(request.starts_with("get /api/workspaces?limit=200 "));
|
||||||
|
assert!(request.contains("authorization: bearer catalog-secret\r\n"));
|
||||||
|
stream
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
let client =
|
||||||
|
BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap();
|
||||||
|
assert!(
|
||||||
|
list_backend_workspaces_with_client(&client)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_request_keeps_operation_key_for_exact_retry() {
|
fn create_request_keeps_operation_key_for_exact_retry() {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
//!
|
//!
|
||||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||||
|
|
||||||
pub mod backend_auth;
|
pub mod backend_api;
|
||||||
|
mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
pub mod backend_workspace;
|
||||||
pub mod runtime_command;
|
pub mod runtime_command;
|
||||||
@@ -18,6 +19,10 @@ pub mod ticket_role;
|
|||||||
mod worker_client;
|
mod worker_client;
|
||||||
mod workspace_product;
|
mod workspace_product;
|
||||||
|
|
||||||
|
pub use backend_api::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path,
|
||||||
|
save_backend_token,
|
||||||
|
};
|
||||||
pub use backend_auth::{
|
pub use backend_auth::{
|
||||||
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse,
|
||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand};
|
use crate::{
|
||||||
|
BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget,
|
||||||
|
BackendRuntimeTarget, WorkerRuntimeCommand,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
@@ -62,11 +65,19 @@ pub struct BackendTarget {
|
|||||||
|
|
||||||
impl BackendTarget {
|
impl BackendTarget {
|
||||||
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
pub fn new(base_url: impl Into<String>, workspace_id: Option<impl Into<String>>) -> Self {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let base_url = BackendOrigin::parse(&base_url)
|
||||||
|
.map(|origin| origin.to_string())
|
||||||
|
.unwrap_or(base_url);
|
||||||
Self {
|
Self {
|
||||||
base_url: base_url.into(),
|
base_url,
|
||||||
workspace_id: workspace_id.map(Into::into),
|
workspace_id: workspace_id.map(Into::into),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn authenticated_client(&self) -> Result<BackendApiClient, BackendApiClientError> {
|
||||||
|
BackendApiClient::from_stored_token(&self.base_url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use workspace_api::{
|
|||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::BackendWorkspaceClientError;
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
|
|
||||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||||
|
|
||||||
@@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse {
|
|||||||
/// Construction requires both the selected Backend URL and Workspace identity.
|
/// Construction requires both the selected Backend URL and Workspace identity.
|
||||||
/// Callers should derive these once from `Target::resolve()` and must not retry
|
/// Callers should derive these once from `Target::resolve()` and must not retry
|
||||||
/// failed requests against repository-local state.
|
/// failed requests against repository-local state.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BackendWorkspaceProductClient {
|
pub struct BackendWorkspaceProductClient {
|
||||||
base_url: String,
|
api: BackendApiClient,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient {
|
|||||||
base_url: impl Into<String>,
|
base_url: impl Into<String>,
|
||||||
workspace_id: impl Into<String>,
|
workspace_id: impl Into<String>,
|
||||||
) -> Result<Self, BackendWorkspaceClientError> {
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
let base_url = base_url.into().trim_end_matches('/').to_string();
|
let base_url = base_url.into();
|
||||||
if base_url.is_empty() {
|
let api = BackendApiClient::from_stored_token(&base_url)?;
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
|
||||||
"Backend base URL must not be empty".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.into();
|
let workspace_id = workspace_id.into();
|
||||||
if workspace_id.trim().is_empty() {
|
if workspace_id.trim().is_empty() {
|
||||||
return Err(BackendWorkspaceClientError::InvalidTarget(
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
"Backend Workspace identity must not be empty".into(),
|
"Backend Workspace identity must not be empty".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(Self {
|
Ok(Self { api, workspace_id })
|
||||||
base_url,
|
}
|
||||||
workspace_id,
|
|
||||||
})
|
#[cfg(test)]
|
||||||
|
fn new_with_access_token(
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
access_token: &str,
|
||||||
|
) -> Result<Self, BackendWorkspaceClientError> {
|
||||||
|
let base_url = base_url.into();
|
||||||
|
let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?;
|
||||||
|
let workspace_id = workspace_id.into();
|
||||||
|
if workspace_id.trim().is_empty() {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Backend Workspace identity must not be empty".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self { api, workspace_id })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn workspace_id(&self) -> &str {
|
pub fn workspace_id(&self) -> &str {
|
||||||
@@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient {
|
|||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<R, BackendWorkspaceClientError> {
|
) -> Result<R, BackendWorkspaceClientError> {
|
||||||
let response = self.request(method, path, body)?.send()?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
let response = ensure_success(response)?;
|
self.api.check_status(response.status())?;
|
||||||
response.json().map_err(BackendWorkspaceClientError::Http)
|
response.json().map_err(BackendWorkspaceClientError::Http)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<(), BackendWorkspaceClientError> {
|
) -> Result<(), BackendWorkspaceClientError> {
|
||||||
ensure_success(self.request(method, path, body)?.send()?)?;
|
let response = self.request(method, path, body)?.send()?;
|
||||||
|
self.api.check_status(response.status())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient {
|
|||||||
path: &str,
|
path: &str,
|
||||||
body: Option<&B>,
|
body: Option<&B>,
|
||||||
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
) -> Result<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
|
||||||
let client = reqwest::blocking::Client::builder().build()?;
|
let path = format!(
|
||||||
let url = format!(
|
"/api/w/{}/{}",
|
||||||
"{}/api/w/{}/{}",
|
|
||||||
self.base_url,
|
|
||||||
encode_path_segment(&self.workspace_id),
|
encode_path_segment(&self.workspace_id),
|
||||||
path.trim_start_matches('/')
|
path.trim_start_matches('/')
|
||||||
);
|
);
|
||||||
let request = client.request(method, url);
|
let request = self.api.blocking_request(method, &path)?;
|
||||||
Ok(match body {
|
Ok(match body {
|
||||||
Some(body) => request.json(body),
|
Some(body) => request.json(body),
|
||||||
None => request,
|
None => request,
|
||||||
@@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError {
|
|||||||
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
TicketError::Sqlite(format!("Backend request failed: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_success(
|
|
||||||
response: reqwest::blocking::Response,
|
|
||||||
) -> Result<reqwest::blocking::Response, BackendWorkspaceClientError> {
|
|
||||||
if response.status().is_success() {
|
|
||||||
return Ok(response);
|
|
||||||
}
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let message = response
|
|
||||||
.text()
|
|
||||||
.unwrap_or_else(|_| "Backend request failed".to_string());
|
|
||||||
Err(BackendWorkspaceClientError::RequestFailed { status, message })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
fn ticket_reference(id: &TicketIdOrSlug) -> String {
|
||||||
match id {
|
match id {
|
||||||
TicketIdOrSlug::Id(id) => id.to_string(),
|
TicketIdOrSlug::Id(id) => id.to_string(),
|
||||||
@@ -698,24 +694,32 @@ mod tests {
|
|||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let response = client.list_objectives(1_000).unwrap();
|
let response = client.list_objectives(1_000).unwrap();
|
||||||
|
|
||||||
assert!(response.items.is_empty());
|
assert!(response.items.is_empty());
|
||||||
assert!(
|
let request = request.recv().unwrap();
|
||||||
request
|
assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 "));
|
||||||
.recv()
|
assert!(request.contains("authorization: Bearer test-backend-token\r\n"));
|
||||||
.unwrap()
|
|
||||||
.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")
|
|
||||||
);
|
|
||||||
handle.join().unwrap();
|
handle.join().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
fn backend_mutation_failure_is_returned_without_local_fallback() {
|
||||||
let (base_url, request, handle) = one_response_server("403 Forbidden", "denied");
|
let (base_url, request, handle) =
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
one_response_server("403 Forbidden", "test-backend-token");
|
||||||
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let error = client
|
let error = client
|
||||||
.create_objective(&ObjectiveCreateRequest {
|
.create_objective(&ObjectiveCreateRequest {
|
||||||
@@ -727,6 +731,7 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|
||||||
assert!(error.to_string().contains("403"));
|
assert!(error.to_string().contains("403"));
|
||||||
|
assert!(!error.to_string().contains("test-backend-token"));
|
||||||
assert!(
|
assert!(
|
||||||
request
|
request
|
||||||
.recv()
|
.recv()
|
||||||
@@ -739,7 +744,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
fn ticket_relation_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let relations = client
|
let relations = client
|
||||||
.query_ticket_relations(
|
.query_ticket_relations(
|
||||||
@@ -758,7 +768,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
fn orchestration_plan_query_uses_workspace_scoped_backend_route() {
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
let (base_url, request, handle) = one_response_server("200 OK", "[]");
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap();
|
||||||
|
|
||||||
@@ -784,7 +799,12 @@ mod tests {
|
|||||||
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.launch_ticket_intake("T-1").unwrap();
|
let status = client.launch_ticket_intake("T-1").unwrap();
|
||||||
|
|
||||||
@@ -806,7 +826,12 @@ mod tests {
|
|||||||
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
fn workspace_orchestrator_launch_uses_scoped_backend_route() {
|
||||||
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#;
|
||||||
let (base_url, request, handle) = one_response_server("200 OK", body);
|
let (base_url, request, handle) = one_response_server("200 OK", body);
|
||||||
let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap();
|
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
base_url,
|
||||||
|
"workspace-a",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let status = client.start_workspace_orchestrator().unwrap();
|
let status = client.start_workspace_orchestrator().unwrap();
|
||||||
|
|
||||||
@@ -822,7 +847,12 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn product_client_requires_workspace_identity() {
|
fn product_client_requires_workspace_identity() {
|
||||||
let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err();
|
let error = BackendWorkspaceProductClient::new_with_access_token(
|
||||||
|
"http://127.0.0.1:8787",
|
||||||
|
"",
|
||||||
|
"test-backend-token",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("Workspace identity"));
|
assert!(error.to_string().contains("Workspace identity"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -422,27 +422,11 @@ fn project_relation(
|
|||||||
kind_key: &str,
|
kind_key: &str,
|
||||||
) -> Result<ModelRelation, String> {
|
) -> Result<ModelRelation, String> {
|
||||||
let relation = object(value, "Ticket relation")?;
|
let relation = object(value, "Ticket relation")?;
|
||||||
let relation_data = relation.get("relation").and_then(Value::as_object);
|
|
||||||
let kind = if kind_key == "kind" {
|
|
||||||
relation_data
|
|
||||||
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
|
|
||||||
.and_then(|data| string_field(data, "kind"))?
|
|
||||||
} else {
|
|
||||||
string_field(relation, kind_key)?
|
|
||||||
};
|
|
||||||
let note = match relation_data {
|
|
||||||
Some(data) => optional_string(data, "note")?,
|
|
||||||
None => optional_string(relation, "note")?,
|
|
||||||
};
|
|
||||||
let created_at = match relation_data {
|
|
||||||
Some(data) => optional_string(data, "at")?,
|
|
||||||
None => optional_string(relation, "at")?,
|
|
||||||
};
|
|
||||||
Ok(ModelRelation {
|
Ok(ModelRelation {
|
||||||
ticket: resource_ref(relation, ticket_key, "T-")?,
|
ticket: resource_ref(relation, ticket_key, "T-")?,
|
||||||
kind,
|
kind: string_field(relation, kind_key)?,
|
||||||
note,
|
note: optional_string(relation, "note")?,
|
||||||
created_at,
|
created_at: optional_string(relation, "at")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -776,6 +760,95 @@ mod tests {
|
|||||||
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
assert!(!objective_json.contains("00001TICKETINTERNAL"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_projection_accepts_current_workspace_api_shapes() {
|
||||||
|
let outgoing = project_relation(
|
||||||
|
&json!({
|
||||||
|
"ticket_id": "internal-source-ticket",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "internal-target-ticket",
|
||||||
|
"target_resource_key": "T-535",
|
||||||
|
"note": "required foundation",
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:00:00Z"
|
||||||
|
}),
|
||||||
|
"target_resource_key",
|
||||||
|
"kind",
|
||||||
|
)
|
||||||
|
.expect("outgoing relation projection");
|
||||||
|
let incoming = project_relation(
|
||||||
|
&json!({
|
||||||
|
"source_ticket": "internal-source-ticket",
|
||||||
|
"source_resource_key": "T-536",
|
||||||
|
"inverse_kind": "blocks",
|
||||||
|
"forward_kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:01:00Z"
|
||||||
|
}),
|
||||||
|
"source_resource_key",
|
||||||
|
"forward_kind",
|
||||||
|
)
|
||||||
|
.expect("incoming relation projection");
|
||||||
|
|
||||||
|
let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation");
|
||||||
|
assert_eq!(
|
||||||
|
outgoing,
|
||||||
|
json!({
|
||||||
|
"ticket": "T-535",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"note": "required foundation",
|
||||||
|
"created_at": "2026-08-22T00:00:00Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let incoming = serde_json::to_value(incoming).expect("serialize incoming relation");
|
||||||
|
assert_eq!(
|
||||||
|
incoming,
|
||||||
|
json!({
|
||||||
|
"ticket": "T-536",
|
||||||
|
"kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"created_at": "2026-08-22T00:01:00Z"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let projection = format!("{outgoing}{incoming}");
|
||||||
|
for internal in [
|
||||||
|
"internal-source-ticket",
|
||||||
|
"internal-target-ticket",
|
||||||
|
"internal-author",
|
||||||
|
] {
|
||||||
|
assert!(!projection.contains(internal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn relation_projection_rejects_missing_workspace_keys() {
|
||||||
|
let outgoing = json!({
|
||||||
|
"kind": "depends_on",
|
||||||
|
"target": "internal-target-ticket",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:00:00Z"
|
||||||
|
});
|
||||||
|
let incoming = json!({
|
||||||
|
"source_resource_key": "not-a-ticket-key",
|
||||||
|
"forward_kind": "depends_on",
|
||||||
|
"note": null,
|
||||||
|
"author": "internal-author",
|
||||||
|
"at": "2026-08-22T00:01:00Z"
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
project_relation(&outgoing, "target_resource_key", "kind")
|
||||||
|
.expect_err("missing outgoing key must fail")
|
||||||
|
.contains("T-")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
project_relation(&incoming, "source_resource_key", "forward_kind")
|
||||||
|
.expect_err("invalid incoming key must fail")
|
||||||
|
.contains("T-")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resource_projection_rejects_noncanonical_keys() {
|
fn resource_projection_rejects_noncanonical_keys() {
|
||||||
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
|
||||||
|
|||||||
+7
-56
@@ -22,7 +22,7 @@ use cli_connection::{
|
|||||||
};
|
};
|
||||||
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login};
|
||||||
use memory_lint::{LintCliOptions, LintStatus};
|
use memory_lint::{LintCliOptions, LintStatus};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use session_store::SegmentId;
|
use session_store::SegmentId;
|
||||||
use tui::{LaunchMode, LaunchOptions};
|
use tui::{LaunchMode, LaunchOptions};
|
||||||
|
|
||||||
@@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ParseError(error.to_string()))?;
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
save_backend_token(backend_url, &token)?;
|
let token_path = client::save_backend_token(backend_url, "Bearer", &token)
|
||||||
println!("Saved Backend API token for {backend_url}");
|
.map_err(|error| ParseError(error.to_string()))?;
|
||||||
Ok(())
|
println!(
|
||||||
}
|
"Saved Backend API token for {} in {}",
|
||||||
|
client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?,
|
||||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
token_path.display()
|
||||||
struct BackendTokenFile {
|
|
||||||
#[serde(default)]
|
|
||||||
tokens: BTreeMap<String, BackendTokenEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
struct BackendTokenEntry {
|
|
||||||
token_type: String,
|
|
||||||
access_token: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> {
|
|
||||||
let path = backend_token_path().ok_or_else(|| {
|
|
||||||
ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string())
|
|
||||||
})?;
|
|
||||||
let mut file = if path.is_file() {
|
|
||||||
let contents = fs::read_to_string(&path)
|
|
||||||
.map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?;
|
|
||||||
serde_json::from_str::<BackendTokenFile>(&contents)
|
|
||||||
.map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))?
|
|
||||||
} else {
|
|
||||||
BackendTokenFile::default()
|
|
||||||
};
|
|
||||||
file.tokens.insert(
|
|
||||||
backend_url.trim_end_matches('/').to_string(),
|
|
||||||
BackendTokenEntry {
|
|
||||||
token_type: "Bearer".to_string(),
|
|
||||||
access_token: access_token.to_string(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|error| {
|
|
||||||
ParseError(format!("failed to create {}: {error}", parent.display()))
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let serialized = serde_json::to_string_pretty(&file)
|
|
||||||
.map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?;
|
|
||||||
fs::write(&path, format!("{serialized}\n"))
|
|
||||||
.map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn backend_token_path() -> Option<PathBuf> {
|
|
||||||
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yoi_config_dir() -> Option<PathBuf> {
|
|
||||||
if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") {
|
|
||||||
return Some(PathBuf::from(home).join("yoi"));
|
|
||||||
}
|
|
||||||
std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||||
let Some((subcommand, rest)) = args.split_first() else {
|
let Some((subcommand, rest)) = args.split_first() else {
|
||||||
return Err(ParseError(
|
return Err(ParseError(
|
||||||
|
|||||||
Reference in New Issue
Block a user